Compare commits

...

8 Commits

20 changed files with 1269 additions and 1389 deletions

View File

@ -3,24 +3,30 @@ extends Node
signal archive_loaded
# Constants
const CURRENT_VERSION = 6
const ARCHIVE_ID_MIN = 0
const ARCHIVE_ID_MAX = 99
const ARCHIVE_ID_DIGITS = 3
# Static paths
static var user_data_root_dir := "user://data/" # must end with "/"
static var user_archives_dir := "user://data/archives/"
static var archive_prefix := "save"
# Archive management
var archive: AssembledArchive:
set(val):
archive = val
if archive:
# emit signal
archive_loaded.emit()
GlobalConfigManager.print_global_info()
print("use archive ", archive.resource_path)
archive.event_stage["release_stage"] = GlobalConfig.RELEASE_STAGE
print_rich("[color=brown] release_stage = %s[/color]" % GlobalConfig.RELEASE_STAGE)
# current archive
static var user_root_dir := "user://data/" # must end with "/"
static var archive_dir := "user://data/archives/"
static var archive_prefix := "save"
const CURRENT_VERSION = 6
var archives_dict: Dictionary[int, AssembledArchive] = {}
var archives_notes_dict: Dictionary[int, String] = {}
var autosave_timer := Timer.new()
@ -32,24 +38,22 @@ func _ready() -> void:
# 禁用默认退出行为,在 _notification 处理 NOTIFICATION_WM_CLOSE_REQUEST 时保存数据
get_tree().set_auto_accept_quit(false)
process_mode = Node.PROCESS_MODE_ALWAYS
if not _check_dirs_and_archives():
_handle_load_error("存档目录", "读写")
return
autosave_timer.timeout.connect(_try_auto_save)
autosave_timer.stop()
add_child(autosave_timer)
_setup_autosave_timer()
# config should be loaded first
load_config()
# 在 debug or editor 模式下,直接保证有 archive
if GlobalConfig.DEBUG or Engine.is_editor_hint():
if archives_dict.size() == 0:
create_and_use_new_archive(0)
else:
# debug 模式下默认使用 0 号存档
GlobalConfigManager.config.current_selected_archive_id = 0
_ensure_debug_archive()
func _notification(what):
func _notification(what: int) -> void:
# handle window close request
if what == NOTIFICATION_WM_CLOSE_REQUEST:
save_all()
@ -59,14 +63,27 @@ func _notification(what):
SceneManager.quit_game()
func _on_archive_id_changed():
func _setup_autosave_timer() -> void:
autosave_timer.timeout.connect(_try_auto_save)
autosave_timer.stop()
add_child(autosave_timer)
func _ensure_debug_archive() -> void:
if archives_dict.is_empty():
create_and_use_new_archive(0)
else:
# debug 模式下默认使用 0 号存档
GlobalConfigManager.config.current_selected_archive_id = 0
func _on_archive_id_changed() -> void:
var selected_id = GlobalConfigManager.config.current_selected_archive_id
if selected_id < 0:
return
# if archive and selected_id == archive.archive_id:
# print("_on_archive_id_changed same id=", selected_id)
# return
print("_on_archive_id_changed id=", selected_id)
if not archives_dict.has(selected_id):
print("新建存档 ", selected_id)
create_and_use_new_archive(selected_id)
@ -76,16 +93,19 @@ func _on_archive_id_changed():
load_archive()
func check_autosave_options():
if (
GlobalConfigManager.config.auto_save_enabled
and archive
and GlobalConfigManager.config.auto_save_seconds > 1
):
func check_autosave_options() -> void:
var config = GlobalConfigManager.config
var should_enable_autosave = (
config.auto_save_enabled
and archive
and config.auto_save_seconds > 1
)
if should_enable_autosave:
# reset left time
autosave_timer.stop()
autosave_timer.one_shot = false
autosave_timer.wait_time = GlobalConfigManager.config.auto_save_seconds
autosave_timer.wait_time = config.auto_save_seconds
autosave_timer.start()
else:
autosave_timer.stop()
@ -93,15 +113,16 @@ func check_autosave_options():
if GlobalConfig.DEBUG:
print(
"check_autosave_option: ",
GlobalConfigManager.config.auto_save_enabled,
config.auto_save_enabled,
" wait_time=",
autosave_timer.wait_time
)
func _try_auto_save():
func _try_auto_save() -> void:
if GlobalConfig.DEBUG:
print("Auto save")
if archive and GlobalConfigManager.config.auto_save_seconds > 1:
save_all()
# 自动保存成功 [ID:ui_auto_saved]
@ -109,72 +130,109 @@ func _try_auto_save():
func _check_dirs_and_archives() -> bool:
if !DirAccess.dir_exists_absolute(user_root_dir):
DirAccess.make_dir_recursive_absolute(user_root_dir)
print("Create user_root_dir:", user_root_dir)
# Ensure directories exist
_ensure_directory_exists(user_data_root_dir)
_ensure_directory_exists(user_archives_dir)
# Check if the archive directory is accessible
if !DirAccess.dir_exists_absolute(archive_dir):
DirAccess.make_dir_recursive_absolute(archive_dir)
print("Create archive_dir:", archive_dir)
var archive_dir_access = DirAccess.open(archive_dir)
if !archive_dir_access:
var archive_dir_access = DirAccess.open(user_archives_dir)
if not archive_dir_access:
_handle_load_error("存档目录", "读取")
# TODO pop up a dialog to inform the user
return false
var files = archive_dir_access.get_files()
files.sort()
# get archive number
for file in files:
if file.begins_with(archive_prefix) and file.ends_with(GlobalConfig.RES_FILE_FORMAT):
# format: save012_xxxxx; save000
var id_and_note = file.get_basename().substr(archive_prefix.length()).strip_escapes().split("_", true, 1)
var id_str = id_and_note[0]
var note_str = id_and_note[1] if id_and_note.size() >= 2 else (archive_prefix + id_str)
# 非三位数的 id 会被忽略
if id_str.length() != 3:
continue
var id = int(id_str)
# 读取范围是 0-99
if id < 0 or id > 99:
continue
archives_notes_dict[id] = note_str
var path = archive_dir + file
if not archives_dict.has(id):
var res = ResourceLoader.load(
path, "AssembledArchive", ResourceLoader.CACHE_MODE_REPLACE_DEEP
)
if is_instance_valid(res) and res.version >= CURRENT_VERSION:
archives_dict[id] = res
else:
printerr("SKIP INVALID ARCHIVE! file=", file)
# Load existing archives
_load_existing_archives(archive_dir_access)
return true
func _ensure_directory_exists(dir_path: String) -> void:
if not DirAccess.dir_exists_absolute(dir_path):
DirAccess.make_dir_recursive_absolute(dir_path)
print("Create directory:", dir_path)
func _load_existing_archives(dir_access: DirAccess) -> void:
var files = dir_access.get_files()
files.sort()
for file in files:
if not _is_valid_archive_filename(file):
continue
var archive_info = _parse_archive_filename(file)
if not archive_info:
continue
var id = archive_info.id
var note = archive_info.note
archives_notes_dict[id] = note
if not archives_dict.has(id):
var archive_resource = _load_archive_resource(user_archives_dir + file)
if archive_resource:
archives_dict[id] = archive_resource
func _is_valid_archive_filename(filename: String) -> bool:
return filename.begins_with(archive_prefix) and filename.ends_with(GlobalConfig.RES_FILE_FORMAT)
func _parse_archive_filename(filename: String) -> Dictionary:
# format: save012_xxxxx; save000
var basename = filename.get_basename()
var id_and_note = basename.substr(archive_prefix.length()).strip_escapes().split("_", true, 1)
var id_str = id_and_note[0]
# 非三位数的 id 会被忽略
if id_str.length() != ARCHIVE_ID_DIGITS:
return {}
var id = int(id_str)
# 读取范围是 0-99
if id < ARCHIVE_ID_MIN or id > ARCHIVE_ID_MAX:
return {}
var note_str = id_and_note[1] if id_and_note.size() >= 2 else (archive_prefix + id_str)
return {
"id": id,
"note": note_str
}
func _load_archive_resource(path: String) -> AssembledArchive:
var res = ResourceLoader.load(
path, "AssembledArchive", ResourceLoader.CACHE_MODE_REPLACE_DEEP
)
if is_instance_valid(res) and res.version >= CURRENT_VERSION:
return res
else:
printerr("SKIP INVALID ARCHIVE! path=", path)
return null
# id = -1 means create a new archive, otherwise create an archive with the given id
func create_and_use_new_archive(id := -1) -> void:
_check_dirs_and_archives()
var archive_path = _get_archive_path(id)
if id < 0:
# 如果 id 小于 0找到一个新的 id创建新存档
id = 0
# find a new id
archive_path = _get_archive_path(id)
while FileAccess.file_exists(archive_path):
id += 1
archive_path = _get_archive_path(id)
id = _find_next_available_id()
_create_and_save_new_archive_resoure(id)
else:
# 如果 id 大于等于 0创建指定 id 的存档
if FileAccess.file_exists(archive_path):
_create_and_save_new_archive_resoure(id, true)
else:
_create_and_save_new_archive_resoure(id)
var archive_path = get_archive_path(id)
var take_over_path = FileAccess.file_exists(archive_path)
_create_and_save_new_archive_resoure(id, take_over_path)
# this will auto trigger signal and load the new archive
GlobalConfigManager.config.current_selected_archive_id = id
func _create_and_save_new_archive_resoure(id, take_over_path = false) -> void:
var archive_path = _get_archive_path(id)
func _find_next_available_id() -> int:
var id = 0
var archive_path = get_archive_path(id)
while FileAccess.file_exists(archive_path) and id <= ARCHIVE_ID_MAX:
id += 1
archive_path = get_archive_path(id)
return id
func _create_and_save_new_archive_resoure(id: int, take_over_path := false) -> void:
var archive_path = get_archive_path(id)
archive = AssembledArchive.new() as Resource
archive.version = CURRENT_VERSION
if take_over_path:
@ -188,15 +246,9 @@ func _create_and_save_new_archive_resoure(id, take_over_path = false) -> void:
# 超过 999 个存档会出问题;不过这个游戏不会有这么多存档
func _get_archive_path(id: int) -> String:
var id_str := ""
if id < 10:
id_str = "00" + str(id)
elif id < 100:
id_str = "0" + str(id)
else:
id_str = str(id)
return archive_dir + archive_prefix + id_str + GlobalConfig.RES_FILE_FORMAT
func get_archive_path(id: int) -> String:
var id_str := str(id).pad_zeros(ARCHIVE_ID_DIGITS)
return user_archives_dir + archive_prefix + id_str + GlobalConfig.RES_FILE_FORMAT
func allow_resume(id := 1) -> bool:
@ -208,41 +260,54 @@ func save_all() -> void:
var config = GlobalConfigManager.config
if config:
ResourceSaver.save(config)
# player_global_position
var player = SceneManager.get_player() as MainPlayer
# 在此处保存 player 的位置信息
if archive and player:
archive.player_global_position_x = player.global_position.x
archive.player_direction = player.facing_direction
# save player state
_save_player_state()
# save archive
if archive:
ResourceSaver.save(archive)
# reset autosave timer
check_autosave_options()
func _save_player_state() -> void:
if not archive:
return
var player = SceneManager.get_player() as MainPlayer
if player:
archive.player_global_position_x = player.global_position.x
archive.player_direction = player.facing_direction
func load_config() -> void:
if GlobalConfigManager.config:
return
var path = user_root_dir + "config" + GlobalConfig.RES_FILE_FORMAT
var path = user_data_root_dir + "config" + GlobalConfig.RES_FILE_FORMAT
if FileAccess.file_exists(path):
var config = ResourceLoader.load(path)
if is_instance_valid(config) and config.version >= CURRENT_VERSION:
GlobalConfigManager.config = config
var loaded_config = ResourceLoader.load(path)
if is_instance_valid(loaded_config) and loaded_config.version >= CURRENT_VERSION:
GlobalConfigManager.config = loaded_config
else:
printerr("SKIP INVALID CONFIG!")
if GlobalConfigManager.config == null:
var config = GlobalConfig.new()
config.version = CURRENT_VERSION
GlobalConfigManager.config = config
ResourceSaver.save(config, path)
GlobalConfigManager.config.resource_path = path
if Engine.is_editor_hint():
return
# connect signals
GlobalConfigManager.config.current_selected_archive_id_changed.connect(_on_archive_id_changed)
GlobalConfigManager.config.auto_save_seconds_changed.connect(check_autosave_options)
GlobalConfigManager.config.auto_save_enabled_changed.connect(check_autosave_options)
if not GlobalConfigManager.config:
_create_default_config(path)
if not Engine.is_editor_hint():
_connect_config_signals()
func _create_default_config(path: String) -> void:
var config = GlobalConfig.new()
config.version = CURRENT_VERSION
config.resource_path = path
GlobalConfigManager.config = config
ResourceSaver.save(config, path)
func _connect_config_signals() -> void:
var config = GlobalConfigManager.config
config.current_selected_archive_id_changed.connect(_on_archive_id_changed)
config.auto_save_seconds_changed.connect(check_autosave_options)
config.auto_save_enabled_changed.connect(check_autosave_options)
func load_archive() -> void:
@ -250,19 +315,16 @@ func load_archive() -> void:
var selected_id = 0
if GlobalConfigManager.config:
selected_id = GlobalConfigManager.config.current_selected_archive_id
# if archive and selected_id == archive.archive_id:
# return
print("load_archive ", selected_id)
if not archives_dict.has(selected_id):
_handle_load_error(str(selected_id) + " 号存档", "查找")
return
archive = archives_dict[selected_id]
# emit signal
archive_loaded.emit()
check_autosave_options()
func _handle_load_error(target, action) -> void:
func _handle_load_error(target: String, action: String) -> void:
var msg = str(target) + " " + str(action) + " failed. Permission Error."
SceneManager.pop_notification(msg)
printerr(msg)
@ -281,9 +343,7 @@ func get_global_value(property: StringName, default = null) -> Variant:
printerr("Archive is null, cannot get global value")
return default
var val = archive.global_data_dict.get(property)
if val == null:
return default
return val
return default if val == null else val
func set_chapter_if_greater(c: int) -> void:
@ -291,7 +351,9 @@ func set_chapter_if_greater(c: int) -> void:
printerr("Archive is null, cannot set chapter")
return
# 1:序章2-5:一四章6:结尾
if c < 1 or c > 6:
const MIN_CHAPTER = 1
const MAX_CHAPTER = 6
if c < MIN_CHAPTER or c > MAX_CHAPTER:
printerr("[ArchiveManager] set_chapter_if_greater: invalid chapter value: " + str(c))
return
if EventManager.get_chapter_stage() >= c:
@ -302,14 +364,14 @@ func set_chapter_if_greater(c: int) -> void:
func unlock_memory(id: int) -> void:
if archive:
if archive.mem_display_dict.get(id):
print("memory already unlocked. id=", id)
return
archive.mem_display_dict[id] = true
SceneManager.pop_notification("ui_notify_mem_update")
else:
if not archive:
printerr("Archive is null, cannot unlock memory. id=", id)
return
if archive.mem_display_dict.get(id):
print("memory already unlocked. id=", id)
return
archive.mem_display_dict[id] = true
SceneManager.pop_notification("ui_notify_mem_update")
# 供运行时缓存跨场景数据

View File

@ -11,8 +11,8 @@ class_name AssembledArchive extends Resource
printerr("[AssembledArchive] current_scene is not valid: " + val)
return
# 尝试后台预先加载该场景
if GroundLoader.GROUND_SCENE_PATH_DICT.has(val):
var path = GroundLoader.GROUND_SCENE_PATH_DICT[val]
var path = GroundLoader.get_ground_scene_uid(val)
if path:
if GlobalConfig.DEBUG:
print("[AssembledArchive] current_scene: " + current_scene)
ResourceLoader.load_threaded_request(path, "PackedScene")

View File

@ -0,0 +1,314 @@
extends Control
# UI References
@onready var quit_debug_button: Button = %QuitDebugModeButton
@onready var archive_grid: GridContainer = %ArchiveGrid
@onready var scroll_container: ScrollContainer = %ScrollContainer
@onready var current_archive_label: Label = %CurrentArchiveLabel
@onready var name_input: LineEdit = %NameInput
@onready var save_button: Button = %SaveButton
@onready var refresh_button: Button = %RefreshButton
# Constants
const MAX_MANUAL_ARCHIVES = 99
const GRID_COLUMNS = 4
# Variables
var manual_archives: Dictionary = {} # {id: {name: String, path: String, time: String}}
var next_available_id: int = 2 # Start from 2 since 1 is reserved for main archive
func _ready() -> void:
get_parent().layer = GlobalConfig.CANVAS_LAYER_SETTINGS
# Setup UI
archive_grid.columns = GRID_COLUMNS
# Connect signals
quit_debug_button.pressed.connect(_on_quit_debug_button_pressed)
save_button.pressed.connect(_on_save_button_pressed)
refresh_button.pressed.connect(_refresh_archive_list)
name_input.text_submitted.connect(_on_name_submitted)
# Set default name
_update_default_name()
# Initial load
_refresh_archive_list()
_update_current_archive_label()
func _update_current_archive_label() -> void:
current_archive_label.text = "当前使用存档1号存档主存档"
func _update_default_name() -> void:
name_input.placeholder_text = "输入存档名称"
var chapter_name = EventManager.get_chapter_stage()
if chapter_name == 1:
chapter_name = "序章"
elif chapter_name <= 5:
chapter_name = "%s" % (chapter_name - 1)
elif chapter_name == 6:
chapter_name = "结尾"
else:
chapter_name = "未知"
var scene_name = SceneManager.get_current_scene_name()
var saving_name = chapter_name + "_" + scene_name
name_input.text = _get_unique_archive_name(saving_name)
func _on_name_submitted(_text: String) -> void:
_on_save_button_pressed()
func _on_quit_debug_button_pressed() -> void:
# 不写入配置
GlobalConfig.DEBUG = false
quit()
func _on_save_button_pressed() -> void:
# Check limit
if manual_archives.size() >= MAX_MANUAL_ARCHIVES:
_show_notification("已达到最大存档数量限制99个")
return
# Get and validate name
var archive_name = name_input.text.strip_edges()
if archive_name.is_empty():
archive_name = "未命名存档_" + Time.get_datetime_string_from_system()
# Save current progress
ArchiveManager.save_all()
# Get unique name
archive_name = _get_unique_archive_name(archive_name)
# Copy current archive
_copy_current_archive(archive_name)
# Reset input field
_update_default_name()
name_input.select_all()
func _get_unique_archive_name(base_name: String) -> String:
var final_name = base_name
var counter = 1
# Check if name already exists
var name_exists = true
while name_exists:
name_exists = false
for data in manual_archives.values():
if data.name == final_name:
name_exists = true
final_name = base_name + "_" + str(counter)
counter += 1
break
return final_name
func _copy_current_archive(archive_name: String) -> void:
# Get current archive path
var current_archive = ArchiveManager.archive
if not current_archive:
_show_notification("当前没有活动存档")
return
# Find next available ID
while manual_archives.has(next_available_id) and next_available_id <= MAX_MANUAL_ARCHIVES + 1:
next_available_id += 1
if next_available_id > MAX_MANUAL_ARCHIVES + 1:
_show_notification("无法创建更多存档")
return
# Create new archive path
var new_archive_path = (
ArchiveManager.user_archives_dir
+ "manual_"
+ str(next_available_id)
+ "_"
+ archive_name.validate_filename()
+ GlobalConfig.RES_FILE_FORMAT
)
# Copy the archive file
var source_path = current_archive.resource_path
var dir = DirAccess.open(ArchiveManager.user_archives_dir)
if dir:
var error = dir.copy(source_path, new_archive_path)
print("Copying archive from: ", source_path, " to: ", new_archive_path)
if error == OK:
# Save manual archive info
manual_archives[next_available_id] = {
"name": archive_name,
"path": new_archive_path,
"time": Time.get_datetime_string_from_system(false, true)
}
# Save manual archives data
_save_manual_archives_data()
# Refresh UI
_refresh_archive_list()
_show_notification("存档已保存:" + archive_name)
next_available_id += 1
else:
_show_notification("存档复制失败:" + error_string(error))
else:
_show_notification("无法访问存档目录")
func _refresh_archive_list() -> void:
# refresh savings name
_update_default_name()
# Clear existing items
for child in archive_grid.get_children():
child.queue_free()
# Load manual archives data
_load_manual_archives_data()
# Create UI items for each manual archive
var sorted_ids = manual_archives.keys()
sorted_ids.sort()
for id in sorted_ids:
var data = manual_archives[id]
_create_archive_item(id, data)
func _create_archive_item(id: int, data: Dictionary) -> void:
# Create container for the archive item
var item_container = PanelContainer.new()
item_container.custom_minimum_size = Vector2(200, 60)
var vbox = VBoxContainer.new()
vbox.add_theme_constant_override("separation", 4)
item_container.add_child(vbox)
# Archive name (editable)
var name_edit = LineEdit.new()
name_edit.text = data.name
name_edit.tooltip_text = "创建时间:" + data.time
vbox.add_child(name_edit)
# Time label
var time_label = Label.new()
time_label.text = data.time
time_label.add_theme_font_size_override("font_size", 12)
time_label.modulate.a = 0.7
vbox.add_child(time_label)
# Action buttons container
var button_container = HBoxContainer.new()
button_container.add_theme_constant_override("separation", 4)
# Load button
var load_btn = Button.new()
load_btn.text = "加载"
load_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button_container.add_child(load_btn)
# Delete button
var delete_btn = Button.new()
delete_btn.text = "删除"
delete_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button_container.add_child(delete_btn)
vbox.add_child(button_container)
# Connect signals
name_edit.text_changed.connect(func(new_text): _on_archive_renamed(id, new_text))
load_btn.pressed.connect(func(): _load_manual_archive(id, data))
delete_btn.pressed.connect(func(): _delete_manual_archive(id))
archive_grid.add_child(item_container)
func _on_archive_renamed(id: int, new_name: String) -> void:
if new_name.strip_edges().is_empty():
return
# Update name in data
manual_archives[id].name = new_name.strip_edges()
# Save changes
_save_manual_archives_data()
func _load_manual_archive(_id: int, data: Dictionary) -> void:
# Save current state first
ArchiveManager.save_all()
# Copy manual archive to archive 1
var dir = DirAccess.open(ArchiveManager.user_archives_dir)
if dir:
var saving_archive = load(data.path)
if saving_archive:
var target_path = ArchiveManager.get_archive_path(1)
print("Loading archive from: ", data.path, " to: ", target_path)
GlobalConfigManager.config.current_selected_archive_id = 1
if ArchiveManager.archive:
saving_archive.take_over_path(target_path)
else:
saving_archive.resource_path = target_path
ArchiveManager.archives_dict[1] = saving_archive
ArchiveManager.archive = saving_archive
print("Loading archive from: ", data.path, " to: ", target_path)
# Reload current scene
get_tree().reload_current_scene()
else:
_show_notification("加载存档失败:" + data.name)
func _delete_manual_archive(id: int) -> void:
var data = manual_archives[id]
# Delete file
var dir = DirAccess.open(ArchiveManager.user_archives_dir)
if dir:
dir.remove(data.path)
# Remove from dictionary
manual_archives.erase(id)
# Save changes
_save_manual_archives_data()
# Refresh UI
_refresh_archive_list()
_show_notification("已删除存档:" + data.name)
func _save_manual_archives_data() -> void:
var save_path = ArchiveManager.user_data_root_dir + "test_manual_archives.dat"
var file = FileAccess.open(save_path, FileAccess.WRITE)
if file:
file.store_var(manual_archives)
file.close()
func _load_manual_archives_data() -> void:
var save_path = ArchiveManager.user_data_root_dir + "test_manual_archives.dat"
if FileAccess.file_exists(save_path):
var file = FileAccess.open(save_path, FileAccess.READ)
if file:
manual_archives = file.get_var()
file.close()
# Find next available ID
next_available_id = 2
for id in manual_archives.keys():
if id >= next_available_id:
next_available_id = id + 1
func _show_notification(message: String) -> void:
SceneManager.pop_notification(message)
func _enter_tree() -> void:
SceneManager.toggle_pause_counter(true, "savings")
func _exit_tree() -> void:
SceneManager.toggle_pause_counter(false, "savings")
func quit() -> void:
queue_free()
func _unhandled_input(event: InputEvent) -> void:
# savings 界面接受所有输入事件
get_viewport().set_input_as_handled()
if (
event.is_action_pressed("savings")
or event.is_action_pressed("cancel")
or event.is_action_pressed("escape")
):
quit()

View File

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

View File

@ -0,0 +1,97 @@
[gd_scene load_steps=2 format=3 uid="uid://d4jeeteyq8kk3"]
[ext_resource type="Script" uid="uid://cirf1nw72l315" path="res://manager/archive_manager/savings_panel.gd" id="1_oo2ip"]
[node name="SavingsLayer" type="CanvasLayer"]
[node name="TestArchivePanel" type="PanelContainer" parent="."]
process_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 59.0
offset_top = 21.0
offset_right = 395.0
offset_bottom = 262.0
grow_horizontal = 2
grow_vertical = 2
scale = Vector2(0.5, 0.5)
script = ExtResource("1_oo2ip")
[node name="VBoxContainer" type="VBoxContainer" parent="TestArchivePanel"]
layout_mode = 2
[node name="HBoxContainer" type="HBoxContainer" parent="TestArchivePanel/VBoxContainer"]
layout_mode = 2
[node name="Title" type="Label" parent="TestArchivePanel/VBoxContainer/HBoxContainer"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 24
text = "存档测试管理器(测试专用)"
horizontal_alignment = 1
[node name="QuitDebugModeButton" type="Button" parent="TestArchivePanel/VBoxContainer/HBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
text = "关闭 Debug 模式"
[node name="存档管理" type="VBoxContainer" parent="TestArchivePanel/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="CurrentArchiveLabel" type="Label" parent="TestArchivePanel/VBoxContainer/存档管理"]
unique_name_in_owner = true
layout_mode = 2
theme_override_colors/font_color = Color(0.7, 0.9, 0.7, 1)
text = "当前使用存档1号存档主存档"
[node name="HSeparator" type="HSeparator" parent="TestArchivePanel/VBoxContainer/存档管理"]
layout_mode = 2
[node name="SaveContainer" type="HBoxContainer" parent="TestArchivePanel/VBoxContainer/存档管理"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="NameInput" type="LineEdit" parent="TestArchivePanel/VBoxContainer/存档管理/SaveContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "输入存档名称"
[node name="SaveButton" type="Button" parent="TestArchivePanel/VBoxContainer/存档管理/SaveContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(120, 0)
layout_mode = 2
text = "保存当前进度"
[node name="RefreshButton" type="Button" parent="TestArchivePanel/VBoxContainer/存档管理/SaveContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(60, 0)
layout_mode = 2
text = "刷新"
[node name="HSeparator2" type="HSeparator" parent="TestArchivePanel/VBoxContainer/存档管理"]
layout_mode = 2
[node name="Label" type="Label" parent="TestArchivePanel/VBoxContainer/存档管理"]
layout_mode = 2
theme_override_colors/font_color = Color(0.8, 0.8, 0.8, 1)
text = "手动存档列表:"
[node name="ScrollContainer" type="ScrollContainer" parent="TestArchivePanel/VBoxContainer/存档管理"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 380)
layout_mode = 2
size_flags_vertical = 3
horizontal_scroll_mode = 0
vertical_scroll_mode = 2
[node name="ArchiveGrid" type="GridContainer" parent="TestArchivePanel/VBoxContainer/存档管理/ScrollContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_constants/h_separation = 10
theme_override_constants/v_separation = 10
columns = 4

View File

@ -2,21 +2,27 @@ class_name GlobalConfig extends Resource
signal streamer_mode_updated
#const DEBUG = true
static var DEBUG = false
# 影响事件的 release_stage
# 0:demo
# 1:v1.0正式版
const RELEASE_STAGE := 1
# 与 Editor 编辑器有 Debugger 连接
# static var EDITOR = false
# .res would be binary encoded, .tres is text encoded
const RES_FILE_FORMAT = ".tres"
# Audio bus names
const BUS_MASTER := "Master"
const BUS_GAME_SFX := "game_sfx"
const BUS_DIALOG := "dialog"
const LANGUAGE_OPTIONS = ["简体中文", "English"]
const LOCALE_PREFIX_MAPPING = {"zh": 0, "en": 1}
const CAPTION_OPTIONS_DICT = {0: ["上海话", "普通话"], 1: [""]}
const CAPTION_LOCALES_DICT = {0: ["zh_SH", "zh_CN"], 1: ["en"]}
## layers
# 设置
# 设置, DebugPanel
const CANVAS_LAYER_SETTINGS = 30
# note
const CANVAS_LAYER_NOTE = 25
@ -64,10 +70,10 @@ signal current_selected_archive_id_changed
signal auto_save_enabled_changed
signal auto_save_seconds_changed
@export var version: int #存档版本
@export var debug_mode := false # 开启 debug 模式
@export var skip_trailer := false # 跳过 trailer
@export var version: int #存档版本
@export var game_launched_times := 0 # 启动游戏次数
@export var game_launched_times := 0 # 启动游戏次数
@export var game_total_seconds := 0 # 游戏总时长
@export var game_rounds := 1 # 当前周目数
@export var current_selected_archive_id := -1: # 当前选定存档, -1 为未选择
@ -103,3 +109,20 @@ signal auto_save_seconds_changed
if streamer_mode != val:
streamer_mode = val
streamer_mode_updated.emit()
func get_locale_language_name() -> String:
language = wrapi(language, 0, LANGUAGE_OPTIONS.size())
return LANGUAGE_OPTIONS[language]
func get_locale_caption_name() -> String:
language = wrapi(language, 0, LANGUAGE_OPTIONS.size())
var caption_options = CAPTION_OPTIONS_DICT.get(language, [""])
caption = wrapi(caption, 0, caption_options.size())
return caption_options[caption]
func get_locale() -> String:
var locales = CAPTION_LOCALES_DICT.get(language, [""])
caption = wrapi(caption, 0, locales.size())
return locales[caption]

View File

@ -1,129 +1,161 @@
@tool
extends Node
# Constants
const TIMER_INTERVAL := 5.0
const TIMER_LOG_INTERVAL := 6 # 30秒打印一次 (6 * 5秒)
const TIMER_EDITOR_LOG_INTERVAL := 120 # 编辑器中600秒打印一次
# Static config
static var config: GlobalConfig:
set = _set_config
var timer = Timer.new()
# Timer for tracking game time
var timer := Timer.new()
var _timer_tick_counter := 0
func _ready() -> void:
timer.wait_time = 5.0
_setup_timer()
func _setup_timer() -> void:
timer.wait_time = TIMER_INTERVAL
timer.one_shot = false
timer.timeout.connect(_on_timer_timeout)
add_child(timer)
timer.start()
func _set_config(val: GlobalConfig) -> void:
static func _set_config(val: GlobalConfig) -> void:
config = val
if Engine.is_editor_hint():
if not config or Engine.is_editor_hint():
return
# debug)
_apply_debug_mode()
_apply_window_settings()
_apply_audio_settings()
_apply_locale_settings()
static func _apply_debug_mode() -> void:
if config.debug_mode:
GlobalConfig.DEBUG = true
print_rich("[color=orange]Debug mode enabled[/color]")
# set up window
static func _apply_window_settings() -> void:
var window = Engine.get_main_loop().root.get_window()
if config.window_fullscreen:
get_window().mode = Window.MODE_EXCLUSIVE_FULLSCREEN
# get_window().mode = Window.MODE_FULLSCREEN
window.mode = Window.MODE_EXCLUSIVE_FULLSCREEN
else:
get_window().mode = Window.MODE_WINDOWED
get_window().always_on_top = config.window_top
# set up sound
AudioServer.set_bus_volume_db(AudioServer.get_bus_index("Master"), config.db_master)
AudioServer.set_bus_volume_db(AudioServer.get_bus_index("game_sfx"), config.db_game_sfx)
AudioServer.set_bus_volume_db(AudioServer.get_bus_index("dialog"), config.db_dialog)
window.mode = Window.MODE_WINDOWED
window.always_on_top = config.window_top
static func _apply_audio_settings() -> void:
AudioServer.set_bus_volume_db(
AudioServer.get_bus_index(GlobalConfig.BUS_MASTER), config.db_master
)
AudioServer.set_bus_volume_db(
AudioServer.get_bus_index(GlobalConfig.BUS_GAME_SFX), config.db_game_sfx
)
AudioServer.set_bus_volume_db(
AudioServer.get_bus_index(GlobalConfig.BUS_DIALOG), config.db_dialog
)
prints(
"config load volume_db settings (master, sfx, dialog): ",
config.db_master,
config.db_game_sfx,
config.db_dialog
)
# set locale
update_locale(config.language, config.caption)
static func _apply_locale_settings() -> void:
var locale = config.get_locale()
print("set language to: ", locale)
TranslationServer.set_locale(locale)
# -1 null; 0 zh; 1 en
var language_options = ["简体中文", "English"]
var caption_options_dict = {0: ["上海话", "普通话"], 1: [""]}
var caption_loacles_dict = {0: ["zh_SH", "zh_CN"], 1: ["en"]}
# return example: "中文_简体中文" "English_"
func update_locale(lang_id: int, caption_id: int) -> void:
lang_id = wrapi(lang_id, 0, language_options.size())
var caption_loacles = caption_loacles_dict.get(lang_id)
caption_id = wrapi(caption_id, 0, caption_loacles.size())
var lang = caption_loacles[caption_id]
GlobalConfigManager.config.language = lang_id
GlobalConfigManager.config.caption = caption_id
print("set language to: ", lang)
TranslationServer.set_locale(lang)
config.language = wrapi(lang_id, 0, GlobalConfig.LANGUAGE_OPTIONS.size())
var caption_options = GlobalConfig.CAPTION_OPTIONS_DICT.get(config.language, [""])
config.caption = wrapi(caption_id, 0, caption_options.size())
_apply_locale_settings()
func get_locale_language_name() -> String:
GlobalConfigManager.config.language = wrapi(
GlobalConfigManager.config.language, 0, language_options.size()
)
return language_options[GlobalConfigManager.config.language]
if config:
return config.get_locale_language_name()
return ""
func get_locale_caption_name() -> String:
GlobalConfigManager.config.language = wrapi(
GlobalConfigManager.config.language, 0, language_options.size()
)
var caption_options = caption_options_dict.get(GlobalConfigManager.config.language)
GlobalConfigManager.config.caption = wrapi(
GlobalConfigManager.config.caption, 0, caption_options.size()
)
return caption_options[GlobalConfigManager.config.caption]
if config:
return config.get_locale_caption_name()
return ""
var _on_timer_timeout_counter := 0
func _on_timer_timeout():
var archive := ArchiveManager.archive
if archive and config:
archive.game_seconds += 5
config.game_total_seconds += 5
_on_timer_timeout_counter += 1
# 30s 打印一次,无需首次打印
# ArchiveManager 设置 archive 时会调用 print_global_info
if _on_timer_timeout_counter % 6 == 0:
# editor 中 600s 打印一次
if Engine.is_editor_hint() and _on_timer_timeout_counter % 120 != 0:
return
print_global_info()
# @warning_ignore("intege")
func print_global_info():
func _on_timer_timeout() -> void:
var archive := ArchiveManager.archive
if not archive or not config:
return
# Update game time
archive.game_seconds += int(TIMER_INTERVAL)
config.game_total_seconds += int(TIMER_INTERVAL)
_timer_tick_counter += 1
# Check if should log
if _should_log_game_info():
print_global_info()
func _should_log_game_info() -> bool:
# 30s 打印一次,无需首次打印
if _timer_tick_counter % TIMER_LOG_INTERVAL != 0:
return false
# editor 中 600s 打印一次
if Engine.is_editor_hint() and _timer_tick_counter % TIMER_EDITOR_LOG_INTERVAL != 0:
return false
return true
func print_global_info() -> void:
var archive := ArchiveManager.archive
if not archive or not config:
return
var game_time_str = _format_game_time(archive.game_seconds)
var tick_time_str = _format_tick_time()
var round_info = _get_round_info()
var scene_info = archive.current_scene
prints(
"[timemark]",
Time.get_datetime_string_from_system(),
round_info,
scene_info,
game_time_str + " " + tick_time_str
)
static func _format_game_time(total_seconds: int) -> String:
@warning_ignore("integer_division")
var hour := archive.game_seconds / 3600
var hours := total_seconds / 3600
@warning_ignore("integer_division")
var minute := (archive.game_seconds % 3600) / 60
var second := archive.game_seconds % 60
var minutes := (total_seconds % 3600) / 60
var seconds := total_seconds % 60
return "game:%d:%02d:%02d" % [hours, minutes, seconds]
static func _get_round_info() -> String:
# 0:未开始游戏1:序章2-5:一四章6:结尾
var chapter := EventManager.get_chapter_stage()
var round_info = "r" + str(config.game_rounds) + "_c" + str(chapter)
var game_time_info = "game:" + str(hour) + ":" + str(minute) + ":" + str(second)
# get ticks since game app run
return "r%d_c%d" % [config.game_rounds, chapter]
@warning_ignore_start("integer_division")
static func _format_tick_time() -> String:
var ticks = Time.get_ticks_msec()
@warning_ignore("integer_division")
hour = ticks / 3600000
@warning_ignore("integer_division")
minute = (ticks % 3600000) / 60000
@warning_ignore("integer_division")
second = (ticks % 60000) / 1000
var msec = ticks % 1000
var tick_time_info = (
"tick:" + str(hour) + ":" + str(minute) + ":" + str(second) + "." + str(msec)
)
var time_info = game_time_info + " " + tick_time_info
var scene_info = archive.current_scene
prints("[timemark]", Time.get_datetime_string_from_system(), round_info, scene_info, time_info)
var hours := ticks / 3600000 as int
var minutes := (ticks % 3600000) / 60000 as int
var seconds := (ticks % 60000) / 1000 as int
var msec := ticks % 1000 as int
return "tick:%d:%02d:%02d.%03d" % [hours, minutes, seconds, msec]

View File

@ -53,6 +53,13 @@ func get_ground() -> Ground2D:
return null
func get_current_scene_name() -> String:
var ground = get_ground()
if ground:
return GroundLoader.get_ground_scene_readable_name(ground.scene_name)
return ""
func get_camera_marker() -> CameraFocusMarker:
var ground = get_ground()
if ground:
@ -356,6 +363,15 @@ func enter_main_scene() -> void:
get_tree().paused = false
#### Savings
var savings_scene = preload("uid://d4jeeteyq8kk3")
func show_savings() -> void:
get_tree().current_scene.add_child(savings_scene.instantiate())
#### UX: settings; panel: note, bag, memory ####
var panel_scene = preload("uid://ddlwnsccsmr8u")
@ -394,7 +410,7 @@ func show_settings() -> void:
#### 游戏场景树暂停计数器设置、memory、bag 等菜单都会导致 pause
## 目前有(5类): settings, panel, bag, note, memory
## 目前有(6类): settings, panel, bag, note, memory, savings
var pause_counter_arr: Array[String] = []
var pause_counter_mutex := Mutex.new()

View File

@ -211,6 +211,11 @@ panel={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":80,"key_label":0,"unicode":112,"location":0,"echo":false,"script":null)
]
}
savings={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":96,"key_label":0,"unicode":96,"location":0,"echo":false,"script":null)
]
}
[internationalization]
@ -242,6 +247,7 @@ locale/fallback="zh"
textures/canvas_textures/default_texture_filter=0
textures/vram_compression/import_etc2_astc=true
textures/webp_compression/compression_method=4
[statistics]

View File

@ -1,6 +1,21 @@
@tool
class_name Ground2D extends Node2D
# Constants
const DEFAULT_PLAYER_Y := 70
const CAMERA_MIN_WIDTH := 564.0
const CAMERA_MIN_HEIGHT := 316.0
const PLAYER_PADDING_X := 30.0
const FOOTSTEP_AUDIO = {
"ghost": preload("res://config/audio/sfx/footstep_ghost.tres"),
"硬地面": preload("res://config/audio/sfx/footstep_硬地面.tres"),
"室外": preload("res://config/audio/sfx/footstep_室外.tres"),
"crawling": preload("res://config/audio/sfx/footstep_crawling.tres"),
"盒子猫": preload("res://config/audio/sfx/footstep_meow.tres"),
}
# Exports
@export var scene_name := ""
# 用于在 debug 时态下,指定进入的 portal
@export_enum("left", "right", "1", "2", "3", "4", "5", "6", "7", "8", "9")
@ -12,7 +27,7 @@ var default_portal := "left"
player_y_fixed = val
if is_node_ready():
reset_player_y()
@export var player_y := 70:
@export var player_y := DEFAULT_PLAYER_Y:
set(val):
player_y = val
if is_node_ready():
@ -27,42 +42,24 @@ var default_portal := "left"
@export_tool_button("玩家重置到 portal") var _a1 = move_player_to_portal.bind(default_portal)
@export_tool_button("玩家重置 y 高度") var _a2 = reset_player_y
# var main_scene := preload("res://scene/main.tscn") as PackedScene
@onready var player_line = %PlayerLine2D as Line2D
@onready var reenter_lock = %PlayerReenterLock as PlayerReenterLock
@onready var player = %MainPlayer as MainPlayer
# Nodes
@onready var player_line := %PlayerLine2D as Line2D
@onready var reenter_lock := %PlayerReenterLock as PlayerReenterLock
@onready var player := %MainPlayer as MainPlayer
@onready var directional_light := %DirectionalLight2D as DirectionalLight2D
@onready var bg_sprite = %BGSprite2D as Sprite2D
@onready var foreground = %ParallaxForeground as ParallaxBackground
@onready var camera_focus_marker = %CameraFocusMarker as CameraFocusMarker
@onready var bg_sprite := %BGSprite2D as Sprite2D
@onready var foreground := %ParallaxForeground as ParallaxBackground
@onready var camera_focus_marker := %CameraFocusMarker as CameraFocusMarker
@onready var footstep_audio := %FootstepAudioPlayer as RandomAudioStreamPlayer
@onready var footstep_audio = %FootstepAudioPlayer as RandomAudioStreamPlayer
const FOOTSTEP_AUDIO = {
#"wood": preload("res://config/audio/footstep/footstep_wood.tres"),
#"carpet": preload("res://config/audio/footstep/footstep_carpet.tres"),
# "concrete": preload("res://config/audio/footstep/footstep_concrete.tres"),
#"grass": preload("res://config/audio/footstep/footstep_grass.tres"),
#"snow": preload("res://config/audio/footstep/footstep_snow.tres"),
"ghost": preload("res://config/audio/sfx/footstep_ghost.tres"),
"硬地面": preload("res://config/audio/sfx/footstep_硬地面.tres"),
"室外": preload("res://config/audio/sfx/footstep_室外.tres"),
"crawling": preload("res://config/audio/sfx/footstep_crawling.tres"),
# 待替换
"盒子猫": preload("res://config/audio/sfx/footstep_meow.tres"),
}
var restarting = false
# State
var restarting := false
func _enter_tree() -> void:
# 仅在编辑器中调试时,通过 main 场景启动
if not Engine.is_editor_hint() and (not get_parent() is GroundLoader):
print("restarting... set GlobalConfig.DEBUG = true")
restarting = true
GlobalConfig.DEBUG = true
_restart_from_main()
if not Engine.is_editor_hint() and not (get_parent() is GroundLoader):
_handle_restart()
return
if camera_focus_marker:
camera_focus_marker.enabled = true
@ -72,37 +69,58 @@ func _ready() -> void:
if restarting:
print("restarting: skip ground _ready()")
return
foreground.layer = GlobalConfig.CANVAS_LAYER_FG
# 检查 scene_name 是否合法
scene_name = scene_name.strip_edges()
if get_parent().name.begins_with("S") and (not scene_name or scene_name.length() != 7):
printerr("scene_name is not valid")
_validate_scene_name()
_set_camera_and_player_boundary()
if Engine.is_editor_hint():
return
# 隐藏 player_line
_setup_scene()
_setup_runtime()
func _handle_restart() -> void:
print("restarting... set GlobalConfig.DEBUG = true")
restarting = true
GlobalConfig.DEBUG = true
_restart_from_main()
func _setup_scene() -> void:
foreground.layer = GlobalConfig.CANVAS_LAYER_FG
player_line.visible = false
func _validate_scene_name() -> void:
scene_name = scene_name.strip_edges()
if get_parent().name.begins_with("S") and (not scene_name or scene_name.length() != 7):
printerr("scene_name is not valid")
func _setup_runtime() -> void:
_load_footstep_audio()
# marker 默认就在 foucs player 状态
# camera_focus_marker.focus_node(player)
# %ColorRectTop.visible = true
# %ColorRectBottom.visible = true
# 如果 debug 模式下不通过 GroundLoader 启动,则插入到 main 以下
_setup_player_light()
SceneManager.toggle_hud_display(display_hud)
func _restart_from_main():
func _restart_from_main() -> void:
# _enter_tree, wait for ready
await ready
_ensure_managers_loaded()
_update_archive_for_restart()
get_tree().change_scene_to_file.call_deferred("res://scene/main.tscn")
func _ensure_managers_loaded() -> void:
if not ArchiveManager.archive:
ArchiveManager.load_archive()
if not GlobalConfigManager.config:
ArchiveManager.load_config()
ArchiveManager.archive.current_scene = scene_name
ArchiveManager.archive.entrance_portal = default_portal
# get_tree().change_scene_to_packed.call_deferred(main_scene)
get_tree().change_scene_to_file.call_deferred("res://scene/main.tscn")
func _update_archive_for_restart() -> void:
var archive = ArchiveManager.archive
if archive:
archive.current_scene = scene_name
archive.entrance_portal = default_portal
func get_player() -> MainPlayer:
@ -121,43 +139,65 @@ func get_camera() -> CameraFocusMarker:
return get_node_or_null("CameraFocusMarker") as CameraFocusMarker
func reset_player_y():
func reset_player_y() -> void:
# 从屏幕下边缘算起
if player_y_fixed:
get_player().set_y_from_ground(158.0 - player_y)
var p = get_player()
if p:
p.set_y_from_ground(158.0 - player_y)
func _set_camera_and_player_boundary():
# set current_boarder by bg size
var camera_rect = Rect2(0, -158, 564, 316)
var player_rect = Rect2(0, -158, 564, 316)
if bg_sprite.texture:
var size = bg_sprite.texture.get_size() * bg_sprite.scale
# camera rect
var camera_size = size
camera_size.x += bg_sprite.position.x
camera_size = Vector2(max(564.0, camera_size.x), max(camera_size.y, 316.0))
var camera_upleft = Vector2(0, -camera_size.y / 2.0)
camera_rect = Rect2(camera_upleft, camera_size)
# player rect should be set centered, with 30px x padding
player_rect.position.x = player_line.get_point_position(0).x + player_line.global_position.x
player_rect.size.x = player_line.get_point_position(1).x - player_line.get_point_position(0).x
func _set_camera_and_player_boundary() -> void:
var camera_rect = _calculate_camera_rect()
var player_rect = _calculate_player_rect()
if GlobalConfig.DEBUG:
print("try to _set_camera_and_player_boundary as:", camera_rect, player_rect)
# set_camera_boundary
_apply_camera_limits(camera_rect)
_apply_player_boundary(player_rect)
func _calculate_camera_rect() -> Rect2:
var camera_rect = Rect2(0, -158, CAMERA_MIN_WIDTH, CAMERA_MIN_HEIGHT)
if bg_sprite.texture:
var size = bg_sprite.texture.get_size() * bg_sprite.scale
var camera_size = size
camera_size.x += bg_sprite.position.x
camera_size = Vector2(
max(CAMERA_MIN_WIDTH, camera_size.x), max(camera_size.y, CAMERA_MIN_HEIGHT)
)
var camera_upleft = Vector2(0, -camera_size.y / 2.0)
camera_rect = Rect2(camera_upleft, camera_size)
return camera_rect
func _calculate_player_rect() -> Rect2:
var player_rect = Rect2(0, -158, CAMERA_MIN_WIDTH, CAMERA_MIN_HEIGHT)
if player_line:
var line_start = player_line.get_point_position(0)
var line_end = player_line.get_point_position(1)
player_rect.position.x = line_start.x + player_line.global_position.x
player_rect.size.x = line_end.x - line_start.x
return player_rect
func _apply_camera_limits(camera_rect: Rect2) -> void:
var camera_marker = get_camera()
camera_marker.limit_left = camera_rect.position.x
camera_marker.limit_right = camera_rect.position.x + camera_rect.size.x
camera_marker.limit_top = camera_rect.position.y
camera_marker.limit_bottom = camera_rect.position.y + camera_rect.size.y
# set_player_boundary
get_player().player_movement_rect = player_rect
if camera_marker:
camera_marker.limit_left = camera_rect.position.x
camera_marker.limit_right = camera_rect.position.x + camera_rect.size.x
camera_marker.limit_top = camera_rect.position.y
camera_marker.limit_bottom = camera_rect.position.y + camera_rect.size.y
func _load_footstep_audio():
# foot step sound
func _apply_player_boundary(player_rect: Rect2) -> void:
var p = get_player()
if p:
p.player_movement_rect = player_rect
func _load_footstep_audio() -> void:
footstep_audio.audio_collections.clear()
if footstep_type != "none":
if footstep_type != "none" and FOOTSTEP_AUDIO.has(footstep_type):
var audio = FOOTSTEP_AUDIO[footstep_type] as AudioStreamCollection
footstep_audio.audio_collections.append(audio)
@ -168,29 +208,44 @@ func play_footstep_sound() -> void:
func move_player_to_portal(portal_name: String) -> void:
var node_path = NodePath("DeployLayer/portal_" + portal_name)
var portal_node = get_node_or_null(node_path) as Portal2D
var portal_node = _get_portal_node(portal_name)
var mov_player = get_player() as MainPlayer
if portal_node and mov_player:
mov_player.global_position.x = portal_node.global_position.x
if portal_name == "left":
mov_player.set_facing_direction(Vector2.RIGHT)
elif portal_name == "right":
mov_player.set_facing_direction(Vector2.LEFT)
reset_player_y()
print("[ground] move player to portal:", portal_name, portal_node.global_position)
elif mov_player:
printerr(scene_name, " portal not found: ", node_path)
else:
if not portal_node:
if mov_player:
printerr(scene_name, " portal not found: portal_", portal_name)
else:
printerr("move_player_to_portal player not ready")
return
if not mov_player:
printerr("move_player_to_portal player not ready")
return
_position_player_at_portal(mov_player, portal_node, portal_name)
reset_player_y()
print("[ground] move player to portal:", portal_name, portal_node.global_position)
func _setup_player_light():
func _get_portal_node(portal_name: String) -> Portal2D:
var node_path = NodePath("DeployLayer/portal_" + portal_name)
return get_node_or_null(node_path) as Portal2D
func _position_player_at_portal(
mov_player: MainPlayer, portal_node: Portal2D, portal_name: String
) -> void:
mov_player.global_position.x = portal_node.global_position.x
if portal_name == "left":
mov_player.set_facing_direction(Vector2.RIGHT)
elif portal_name == "right":
mov_player.set_facing_direction(Vector2.LEFT)
func _setup_player_light() -> void:
# 强制显示 directional_light
directional_light.visible = true
# 设置角色身上光源
if directional_light.blend_mode == Light2D.BLEND_MODE_SUB and directional_light.energy > 0.6:
player.enable_light = true
else:
player.enable_light = false
var should_enable_light = (
directional_light.blend_mode == Light2D.BLEND_MODE_SUB and directional_light.energy > 0.6
)
player.enable_light = should_enable_light
print("_setup_player_light player.enable_light=", player.enable_light)

View File

@ -1,6 +1,50 @@
class_name GroundLoader extends Node2D
@export_group("Scene")
# Constants
const DEFAULT_TRANSITION_TIME := 1.4
const MIN_TRANSITION_TIME := 0.6
const EASE_DURATION := 0.3
# Scene name to path mapping
const GROUND_SCENE_PATH_DICT: Dictionary[String, Dictionary] = {
"c01_s05": {"path": "uid://dlx5xxbg53rb8", "name": "院长房间"},
"c01_s06": {"path": "uid://bx16c8nn32f40", "name": "孤儿院长廊"},
"c01_s07": {"path": "uid://ds2iyfndwamiy", "name": "书店外"},
"c01_s08": {"path": "uid://cwu4dhayra8pg", "name": "书店"},
"c01_s09": {"path": "uid://c777lv8mjojcw", "name": "公寓楼外"},
"c01_s10": {"path": "uid://be57l2o3vxxtm", "name": "公寓楼道"},
"c01_s11": {"path": "uid://coiumaaenimbc", "name": "黄包车"},
"c01_s12": {"path": "uid://bol5hl68pbpgq", "name": "诡异书店外"},
"c02_s01": {"path": "uid://bbs7yy5aofw1v", "name": "公寓门口"},
"c02_s02": {"path": "uid://brck77w81fhvc", "name": "公寓楼道"},
"c02_s03": {"path": "uid://djc2uaefhmu7", "name": "一楼院子"},
"c02_s04": {"path": "uid://bivc5cdap370p", "name": "一楼保卫科"},
"c02_s05": {"path": "uid://cp8d3ag5nbjq0", "name": "一楼内侧楼道"},
"c02_s06": {"path": "uid://cootarwb44vvh", "name": "二楼楼道"},
"c02_s07": {"path": "uid://t4xjt774ngwh", "name": "二楼内侧楼道"},
"c02_s08": {"path": "uid://ce2vyyg2reg52", "name": "瞎子卧室"},
"c02_s09": {"path": "uid://ryups1dnwdto", "name": "裂缝空间"},
"c02_s10": {"path": "uid://dny21yhtuteap", "name": "空房间"},
"c02_s11": {"path": "uid://dq41rvwl5hyrk", "name": "一楼火灾"},
"c02_s12": {"path": "uid://da4cuf2i3nwpj", "name": "盒子猫安全屋"},
"c02_s13": {"path": "uid://bvjutch6jex0v", "name": "盒子猫二楼"},
"c02_s14": {"path": "uid://d0p4x5st2r315", "name": "盒子猫二楼内侧"},
"c02_s15": {"path": "uid://b21p53g42j2nt", "name": "盒子猫一楼内侧"},
"c02_s16": {"path": "uid://22hc3oe8t0id", "name": "盒子猫三楼内侧"},
"c02_s17": {"path": "uid://cbr6gbgrl2wb1", "name": "盒子猫三楼"},
"c02_s18": {"path": "uid://d27gv3pbkn4b8", "name": "盒子猫一楼"},
"c03_s01": {"path": "uid://dlrbhfvnd3cs0", "name": "三楼楼道"},
"c03_s02": {"path": "uid://ctwy1ubhm68la", "name": "瞎子卧室"},
"c03_s03": {"path": "uid://bsqt2c061fmin", "name": "瞎子理发店"},
"c03_s04": {"path": "uid://c7c88hg2cl1j7", "name": "李癞房间"},
"c03_s05": {"path": "uid://6ehb3ux2kilu", "name": "胖子肉铺"},
"c03_s06": {"path": "uid://cxacrp8mrrbry", "name": "胖子卧室"},
"c03_s07": {"path": "uid://c67732f2we13j", "name": "屠宰间"},
"c03_s08": {"path": "uid://bixdbbyhroepi", "name": "囚室"},
"c03_s09": {"path": "uid://dfln301xllqpn", "name": "棺材房"},
}
# Exports
@export var ignore_archive := false
@export var current_scene := "c02_s01"
@export var entrance_portal := "left"
@ -9,206 +53,190 @@ class_name GroundLoader extends Node2D
debug_reload = false
if is_node_ready() and current_scene and entrance_portal:
transition_to_scene(current_scene, entrance_portal, 0.0)
# 强制覆盖 archive 记录
@export var force_archive_scene := ""
@export var force_archive_portal := ""
# Nodes
@onready var mask_layer := %MaskLayer as CanvasLayer
@onready var mask := %Mask as ColorRect
# State
var has_entered := false
var ground: Ground2D
var display_start_sec := 0.0
var _frozen_start_time_ms: int
var _allow_ground_start := false:
set(val):
_allow_ground_start = val
if ground and val:
if ground.process_mode != Node.PROCESS_MODE_INHERIT:
SceneManager.ground_start.emit()
ground.process_mode = Node.PROCESS_MODE_INHERIT
print(
"GroundLoader _allow_ground_start: unfrozen. frozen duration(ms):",
Time.get_ticks_msec() - _frozen_start_time_ms
)
# 场景名字映射到路径
static var GROUND_SCENE_PATH_DICT = {
"c01_s05": "uid://dlx5xxbg53rb8",
"c01_s06": "uid://bx16c8nn32f40",
"c01_s07": "uid://ds2iyfndwamiy",
"c01_s08": "uid://cwu4dhayra8pg",
"c01_s09": "uid://c777lv8mjojcw",
"c01_s10": "uid://be57l2o3vxxtm",
"c01_s11": "uid://coiumaaenimbc",
"c01_s12": "uid://bol5hl68pbpgq",
"c02_s01": "uid://bbs7yy5aofw1v",
"c02_s02": "uid://brck77w81fhvc",
"c02_s03": "uid://djc2uaefhmu7",
"c02_s04": "uid://bivc5cdap370p",
"c02_s05": "uid://cp8d3ag5nbjq0",
"c02_s06": "uid://cootarwb44vvh",
"c02_s07": "uid://t4xjt774ngwh",
"c02_s08": "uid://ce2vyyg2reg52",
"c02_s09": "uid://ryups1dnwdto",
"c02_s10": "uid://dny21yhtuteap",
"c02_s11": "uid://dq41rvwl5hyrk", # 注:该场景合并在了 c02_s03 院子中
"c02_s12": "uid://da4cuf2i3nwpj",
"c02_s13": "uid://bvjutch6jex0v",
"c02_s14": "uid://d0p4x5st2r315",
"c02_s15": "uid://b21p53g42j2nt",
"c02_s16": "uid://22hc3oe8t0id",
"c02_s17": "uid://cbr6gbgrl2wb1",
"c02_s18": "uid://d27gv3pbkn4b8",
"c03_s01": "uid://dlrbhfvnd3cs0", # s01_三楼
"c03_s02": "uid://ctwy1ubhm68la", # s03_瞎子卧室
"c03_s03": "uid://bsqt2c061fmin", # s02_瞎子理发店
"c03_s04": "uid://c7c88hg2cl1j7", # s04_李癞房间
"c03_s05": "uid://6ehb3ux2kilu", # s05_肉铺
"c03_s06": "uid://cxacrp8mrrbry", # s06_胖子卧室
"c03_s07": "uid://c67732f2we13j", # s07_屠宰间
"c03_s08": "uid://bixdbbyhroepi", # s08_囚室
"c03_s09": "uid://dfln301xllqpn", # s09_棺材房
}
# Debug
var update_watcher: Timer
var last_modify_time := 0
func _ready() -> void:
mask.visible = true
mask.color.a = 0.0
mask_layer.layer = GlobalConfig.CANVAS_LAYER_GROUND_MASK
# mask layer 独立的 always 处理模式,可以保证转场正常运行
# toggle_mask = mask_layer.toggle_mask
ground = get_node_or_null("Ground") as Ground2D
if ground:
print("GroundLoader remove old ground:", ground.scene_name)
# remove_child(ground)
ground.queue_free()
# load save
_setup_mask_layer()
if not ignore_archive:
_load_save()
if current_scene and entrance_portal:
# 首次进入渐隐效果
transition_to_scene(current_scene, entrance_portal)
# transition_to_scene(current_scene, entrance_portal, 0.0)
# # var toggle_mask:Callable
# func toggle_mask(display: bool, mask_color: Color, wait_time: float) -> Tween:
# return mask_layer.toggle_mask(display, mask_color, wait_time)
var display_start_sec = 0.0
func _setup_mask_layer() -> void:
mask.visible = true
mask.color.a = 0.0
mask_layer.layer = GlobalConfig.CANVAS_LAYER_GROUND_MASK
# wait_time 包含 ease in + wait + ease out 完整时长
# ease duration = min(ease_min_duration, wait_time * 0.5)
func toggle_mask(
display: bool, wait_time: float, ease_min_duration := 0.3, mask_color := Color.BLACK
) -> Tween:
var tween = get_tree().create_tween()
mask_color.a = mask.color.a
mask.color = mask_color
var duration = min(ease_min_duration, wait_time * 0.5)
if display:
display_start_sec = Time.get_ticks_msec() * 0.001
tween.tween_property(mask, "color:a", 1.0, duration).set_trans(Tween.TRANS_CUBIC)
else:
# 转场至少 0.6s, 除去 0.3s 最后的淡出,需要 0.3s 的等待时间(包含 mask 的淡入)
if wait_time:
var time = Time.get_ticks_msec() * 0.001
wait_time = max(wait_time + display_start_sec - time - 0.3, 0.0)
if wait_time:
tween.tween_interval(wait_time)
tween.tween_property(mask, "color:a", 0.0, duration).set_trans(Tween.TRANS_CUBIC)
return tween
func _load_save():
func _load_save() -> void:
# 强制覆盖 archive 记录
if force_archive_scene or force_archive_portal:
current_scene = force_archive_scene
entrance_portal = force_archive_portal
return
if not Engine.is_editor_hint():
if ArchiveManager.archive.current_scene:
current_scene = ArchiveManager.archive.current_scene
if ArchiveManager.archive.entrance_portal:
entrance_portal = ArchiveManager.archive.entrance_portal
if Engine.is_editor_hint():
return
var archive = ArchiveManager.archive
if archive:
if archive.current_scene:
current_scene = archive.current_scene
if archive.entrance_portal:
entrance_portal = archive.entrance_portal
func transition_to_scene(scene_name: String, portal: String, wait_time := 1.4) -> void:
if ground:
print("GroundLoader transition_to_scene: pause prev ground.")
# 先发送,再暂停,允许 sfx 等节点执行 ease out
SceneManager.ground_transition_pre_paused.emit()
ground.set_deferred("process_mode", Node.PROCESS_MODE_DISABLED)
# print reenter lock status
print("GroundLoader transition_to_scene: reenter lock status: ", ground.reenter_lock)
var scene_path = GROUND_SCENE_PATH_DICT.get(scene_name)
if scene_path:
current_scene = scene_name
entrance_portal = portal
# 优先更新 archive使 ground 可以访问自己的 current_scene 键值
if not Engine.is_editor_hint():
_update_archive()
if wait_time > 0.0:
# 转场效果,在 _load_ground_node 之前播放
var tween = toggle_mask(true, wait_time)
tween.tween_callback(_do_transition.call_deferred.bind(scene_name))
_allow_ground_start = false
# 等到 toggle_mask 结束,再重置 freeze 状态
toggle_mask(false, wait_time).tween_callback(func(): _allow_ground_start = true)
else:
_allow_ground_start = true
_do_transition.call_deferred(scene_name)
static func get_ground_scene_uid(scene_name: String) -> String:
if GROUND_SCENE_PATH_DICT.has(scene_name):
return GROUND_SCENE_PATH_DICT[scene_name]["path"]
printerr("GroundLoader get_ground_scene_uid: scene not found:", scene_name)
return ""
static func get_ground_scene_readable_name(scene_name: String) -> String:
if GROUND_SCENE_PATH_DICT.has(scene_name):
return GROUND_SCENE_PATH_DICT[scene_name]["name"]
printerr("GroundLoader get_ground_scene_readable_name: scene not found:", scene_name)
return scene_name
func toggle_mask(
display: bool, wait_time: float, ease_min_duration := EASE_DURATION, mask_color := Color.BLACK
) -> Tween:
var tween = get_tree().create_tween()
mask_color.a = mask.color.a
mask.color = mask_color
var duration = min(ease_min_duration, wait_time * 0.5)
if display:
display_start_sec = Time.get_ticks_msec() * 0.001
tween.tween_property(mask, "color:a", 1.0, duration).set_trans(Tween.TRANS_CUBIC)
else:
# 转场至少 0.6s, 除去 0.3s 最后的淡出,需要 0.3s 的等待时间(包含 mask 的淡入)
if wait_time > 0.0:
var time = Time.get_ticks_msec() * 0.001
wait_time = max(wait_time + display_start_sec - time - EASE_DURATION, 0.0)
if wait_time > 0.0:
tween.tween_interval(wait_time)
tween.tween_property(mask, "color:a", 0.0, duration).set_trans(Tween.TRANS_CUBIC)
return tween
func transition_to_scene(
scene_name: String, portal: String, wait_time := DEFAULT_TRANSITION_TIME
) -> void:
if not GROUND_SCENE_PATH_DICT.has(scene_name):
print("Scene not found: " + scene_name)
return
_pause_current_ground()
current_scene = scene_name
entrance_portal = portal
# 优先更新 archive使 ground 可以访问自己的 current_scene 键值
if not Engine.is_editor_hint():
_update_archive()
if wait_time > 0.0:
_transition_with_effect(scene_name, wait_time)
else:
_allow_ground_start = true
_do_transition.call_deferred(scene_name)
var _frozen_start_time_ms: int
var _allow_ground_start := false:
set(val):
_allow_ground_start = val
if ground:
if val:
if ground.process_mode != Node.PROCESS_MODE_INHERIT:
# ground_start 信号
SceneManager.ground_start.emit()
ground.process_mode = Node.PROCESS_MODE_INHERIT
print(
"GroundLoader _allow_ground_start: unfrozen. frozen duration(ms):",
Time.get_ticks_msec() - _frozen_start_time_ms
)
func _pause_current_ground() -> void:
if not ground:
return
print("GroundLoader transition_to_scene: pause prev ground.")
# 先发送,再暂停,允许 sfx 等节点执行 ease out
SceneManager.ground_transition_pre_paused.emit()
ground.set_deferred("process_mode", Node.PROCESS_MODE_DISABLED)
# print reenter lock status
print("GroundLoader transition_to_scene: reenter lock status: ", ground.reenter_lock)
func _update_archive():
ArchiveManager.archive.current_scene = current_scene
ArchiveManager.archive.entrance_portal = entrance_portal
func _transition_with_effect(scene_name: String, wait_time: float) -> void:
# 转场效果,在 _load_ground_node 之前播放
var tween = toggle_mask(true, wait_time)
tween.tween_callback(_do_transition.call_deferred.bind(scene_name))
_allow_ground_start = false
# 等到 toggle_mask 结束,再重置 freeze 状态
toggle_mask(false, wait_time).tween_callback(func(): _allow_ground_start = true)
func _update_archive() -> void:
var archive = ArchiveManager.archive
if archive:
archive.current_scene = current_scene
archive.entrance_portal = entrance_portal
func _do_transition(scene_name: String) -> void:
# SceneManager.freeze_player(0)
print("GroundLoader Transition to scene:", scene_name, "portal:", entrance_portal)
_remove_current_ground()
_load_and_setup_ground(scene_name)
_add_ground()
if _allow_ground_start:
SceneManager.ground_start.emit()
_post_transition()
func _remove_current_ground() -> void:
ground = get_node_or_null("Ground") as Ground2D
if ground:
# 防止命名冲突
remove_child(ground)
ground.queue_free()
func _load_and_setup_ground(scene_name: String) -> void:
# 先设置 ground再添加到场景中
# 因为 ground 在 enter_tree 时会用到 SceneManager 的方法
# 其中间接用到了 GroundLoader 的 ground
ground = _load_ground_node(scene_name)
if not _allow_ground_start:
ground.set_deferred("process_mode", Node.PROCESS_MODE_DISABLED)
print("GroundLoader not _allow_ground_start: frozen (delayed)")
_frozen_start_time_ms = Time.get_ticks_msec()
_add_ground()
if _allow_ground_start:
# 如果不阻塞,直接 ground_start 信号
SceneManager.ground_start.emit()
# 预先加载邻居场景
_post_transition()
if GlobalConfig.DEBUG and not Engine.is_editor_hint():
_watch_scene_update()
func _add_ground():
func _add_ground() -> void:
ground.ready.connect(SceneManager.ground_ready.emit.bind(ground))
ground.name = "Ground"
# 在 add child 之前,调整 ground 内部元素属性,在 on ground ready 前设置完成
if not Engine.is_editor_hint():
# 更新玩家位置
if not has_entered:
_update_player_position_from_archive()
else:
# move player to portal
ground.move_player_to_portal(entrance_portal)
_setup_player_position()
add_child(ground)
print(
"GroundLoader add_ground finished:",
@ -222,86 +250,66 @@ func _add_ground():
has_entered = true
func _update_player_position_from_archive():
func _setup_player_position() -> void:
if not has_entered:
_update_player_position_from_archive()
else:
ground.move_player_to_portal(entrance_portal)
func _update_player_position_from_archive() -> void:
if ignore_archive or Engine.is_editor_hint():
return
var archive = ArchiveManager.archive
if not archive:
return
var player = ground.get_player() as MainPlayer
player.global_position.x = ArchiveManager.archive.player_global_position_x
player.set_facing_direction(ArchiveManager.archive.player_direction)
ground.reset_player_y()
if player:
player.global_position.x = archive.player_global_position_x
player.set_facing_direction(archive.player_direction)
ground.reset_player_y()
func _load_ground_node(scene_name: String) -> Ground2D:
if not GROUND_SCENE_PATH_DICT.has(scene_name):
return null
var path = GROUND_SCENE_PATH_DICT[scene_name]
var scene: PackedScene
var path = get_ground_scene_uid(scene_name)
var scene: PackedScene = _load_scene_resource(path)
if not scene:
return null
var instance = scene.instantiate() as Node2D
var ground_node = instance.get_child(0)
instance.remove_child(ground_node)
ground_node.owner = null
instance.queue_free()
return ground_node
func _load_scene_resource(path: String) -> PackedScene:
if ResourceLoader.load_threaded_get_status(path) == ResourceLoader.THREAD_LOAD_LOADED:
scene = ResourceLoader.load_threaded_get(path) as PackedScene
return ResourceLoader.load_threaded_get(path) as PackedScene
else:
scene = ResourceLoader.load(path) as PackedScene
if scene:
var instance = scene.instantiate() as Node2D
var ground_node = instance.get_child(0)
instance.remove_child(ground_node)
ground_node.owner = null
instance.queue_free()
return ground_node
return null
return ResourceLoader.load(path) as PackedScene
# 读取 portals预加载邻居场景
func _post_transition():
if ground:
var scene_names = []
var deploy_layer = ground.get_node("DeployLayer")
if deploy_layer:
for node in deploy_layer.get_children():
var portal = node as Portal2D
if not portal or not portal.target_scene:
continue
if GROUND_SCENE_PATH_DICT.has(portal.target_scene):
scene_names.append(portal.target_scene)
if scene_names:
for scene_name in scene_names:
ResourceLoader.load_threaded_request(GROUND_SCENE_PATH_DICT[scene_name])
if GlobalConfig.DEBUG:
print("preload neighbor scenes:", scene_names)
GlobalConfigManager.print_global_info()
func _post_transition() -> void:
if not ground:
return
_preload_neighbor_scenes()
GlobalConfigManager.print_global_info()
var update_watcher: Timer
var last_modify_time = 0
# DEBUG 时重新加载资源
func _watch_scene_update():
var scene_path = GROUND_SCENE_PATH_DICT[current_scene]
if scene_path:
last_modify_time = FileAccess.get_modified_time(scene_path)
if not update_watcher:
update_watcher = Timer.new()
update_watcher.wait_time = 1
update_watcher.one_shot = false
add_child(update_watcher)
update_watcher.start()
else:
# remove all connections
for c in update_watcher.timeout.get_connections():
update_watcher.timeout.disconnect(c.callable)
update_watcher.timeout.connect(_check_scene_update.bind(scene_path))
func _check_scene_update(scene_path):
var modify = FileAccess.get_modified_time(scene_path)
if modify != last_modify_time:
last_modify_time = modify
_on_resources_reload(scene_path)
func _on_resources_reload(res):
print("resources_reload processing:", res)
if not Engine.is_editor_hint() and res.ends_with(".tscn"):
ArchiveManager.save_all()
has_entered = false
transition_to_scene.call_deferred(current_scene, entrance_portal, 0.0)
func _preload_neighbor_scenes() -> void:
var scene_names: Array[String] = []
var deploy_layer = ground.get_node_or_null("DeployLayer")
if not deploy_layer:
return
for node in deploy_layer.get_children():
var portal = node as Portal2D
if portal and portal.target_scene and GROUND_SCENE_PATH_DICT.has(portal.target_scene):
scene_names.append(portal.target_scene)
if GlobalConfig.DEBUG:
print("preload neighbor scenes:", scene_names)
for scene_name in scene_names:
if GROUND_SCENE_PATH_DICT.has(scene_name):
ResourceLoader.load_threaded_request(get_ground_scene_uid(scene_name))

View File

@ -101,15 +101,15 @@ func cant_read():
func _on_note_read():
frame.enabled = false
# 稍加延时后显示鸡毛掸子
Util.timer(4.5, func(): ambush.enabled = true)
Util.timer(1.0, func(): ambush.enabled = true)
func _on_ambush_triggered():
ambush.enabled = false
frame.note_key = "c01_摆正的洋相片"
Util.timer(5.5, func(): frame.enabled = true)
# 鸡毛掸子 4.5s,再等待 3s 后掉落纸片
Util.timer(8, _play_paper_animation)
# 鸡毛掸子 4.5s,再等待 1.5s 后掉落纸片
Util.timer(6.0, _play_paper_animation)
func lock_on_use_stick():

View File

@ -5,8 +5,6 @@
[ext_resource type="AudioStream" uid="uid://cvttds81trcoc" path="res://asset/audio/sfx/UI/click.ogg" id="2_te6ya"]
[ext_resource type="Script" uid="uid://rq6w1vuhuq1m" path="res://scene/entity/audio/sfx.gd" id="3_5oc6i"]
[ext_resource type="Script" uid="uid://bbg4vopj4apl6" path="res://scene/entity/audio/bgm_control.gd" id="4_5oc6i"]
[ext_resource type="AudioStream" uid="uid://b3b4a6nm8557i" path="res://asset/audio/专用/衔蝶_主菜单music剪辑人声版.wav" id="5_5oc6i"]
[ext_resource type="Script" uid="uid://c63lrar71o17d" path="res://scene/index/启动入口.gd" id="7_1sxgt"]
[ext_resource type="AudioStream" uid="uid://b3b4a6nm8557i" path="res://asset/audio/专用/衔蝶_主菜单music剪辑人声版.ogg" id="5_5oc6i"]
[ext_resource type="FontFile" uid="uid://bjmhscwn1ixj1" path="res://asset/font/字体/ChillJinshuSongMedium.otf" id="7_1sxgt"]
@ -162,34 +160,3 @@ grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
color = Color(0, 0, 0, 1)
[node name="启动入口" type="VBoxContainer" parent="."]
layout_mode = 0
offset_left = 298.0
offset_top = 50.0
offset_right = 396.0
offset_bottom = 172.0
script = ExtResource("7_1sxgt")
[node name="Label" type="Label" parent="启动入口"]
layout_mode = 2
text = "【仅测试版本启用】"
[node name="DebugMode" type="Button" parent="启动入口"]
custom_minimum_size = Vector2(50, 0)
layout_mode = 2
text = "退出 debug mode仅本次启动游戏有效若想持续生效需退出游戏后改动 config.tres/.res "
autowrap_mode = 3
[node name="Chapter1" type="Button" parent="启动入口"]
layout_mode = 2
text = "从第一章开始新游戏"
[node name="Chapter2" type="Button" parent="启动入口"]
layout_mode = 2
text = "从第二章开始新游戏"
[node name="Chapter3" type="Button" parent="启动入口"]
visible = false
layout_mode = 2
text = "从第三章开始新游戏"

View File

@ -1,39 +0,0 @@
extends VBoxContainer
func _ready() -> void:
if not GlobalConfig.DEBUG:
queue_free()
return
$DebugMode.pressed.connect(_exit_debug_mode)
$Chapter1.pressed.connect(_start_game.bind(1))
$Chapter2.pressed.connect(_start_game.bind(2))
$Chapter3.pressed.connect(_start_game.bind(3))
func _exit_debug_mode() -> void:
GlobalConfig.DEBUG = false
print_rich("[color=orange]Debug mode disabled[/color]")
queue_free()
func _start_game(chapter: int) -> void:
$"../SfxClick".global_play()
var archive_res: AssembledArchive
if chapter == 1:
archive_res = load("uid://x2yr8b2ul5al")
if archive_res.version != ArchiveManager.CURRENT_VERSION:
printerr("存档版本不匹配,已自动更新存档版本")
archive_res.version = ArchiveManager.CURRENT_VERSION
elif chapter == 2:
archive_res = load("uid://b45y2iffjdieg")
if archive_res.version != ArchiveManager.CURRENT_VERSION:
printerr("存档版本不匹配,已自动更新存档版本")
archive_res.version = ArchiveManager.CURRENT_VERSION
archive_res = archive_res.duplicate(true)
ArchiveManager.create_and_use_new_archive(1)
archive_res.take_over_path(ArchiveManager.archive.resource_path)
ArchiveManager.archive = archive_res
# new game 时音量渐隐
$"../BgmControl".stop(0.5)
SceneManager.enter_main_scene()

View File

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

View File

@ -1,37 +0,0 @@
[gd_resource type="Resource" script_class="AssembledArchive" load_steps=5 format=3 uid="uid://x2yr8b2ul5al"]
[ext_resource type="Script" uid="uid://b63jgb1ag242f" path="res://manager/archive_manager/ground_archive.gd" id="1_na6rk"]
[ext_resource type="Script" uid="uid://cay6xv3x51phl" path="res://scene/ux/prop_inventory_resource.gd" id="2_uc52y"]
[ext_resource type="Script" uid="uid://cusw17h37oc0g" path="res://manager/archive_manager/assembled_archive.gd" id="3_sr6ce"]
[sub_resource type="Resource" id="Resource_6q3u4"]
script = ExtResource("2_uc52y")
owner = ""
current_index = 0
important_items = Array[String]([])
unviewed_important_items = Array[String]([])
default_enabled_items = Array[String]([])
xdie_enabled_items = Array[String]([])
xxdie_enabled_items = Array[String]([])
xxxdie_enabled_items = Array[String]([])
[resource]
script = ExtResource("3_sr6ce")
version = 6
archive_id = 1
entrance_portal = &"left"
current_scene = &"c02_s01"
player_global_position_x = -100.0
player_direction = Vector2(0, 0)
game_seconds = 0
created_time = "2025-07-11 01:22:58"
event_stage = Dictionary[StringName, int]({})
global_data_dict = Dictionary[StringName, Variant]({})
ground_archives = Dictionary[StringName, ExtResource("1_na6rk")]({})
npc_anonymous_states = Dictionary[StringName, bool]({})
player_running_locked = false
prop_inventory = SubResource("Resource_6q3u4")
mem_display_dict = Dictionary[int, bool]({})
bayinhe_current_answer = [0, 0, 0, 0, 0, 0, 0, 0, 0]
bayinhe_mode = "closed"
metadata/_custom_type_script = "uid://cusw17h37oc0g"

View File

@ -1,672 +0,0 @@
[gd_resource type="Resource" script_class="AssembledArchive" load_steps=19 format=3 uid="uid://b45y2iffjdieg"]
[ext_resource type="Script" uid="uid://b63jgb1ag242f" path="res://manager/archive_manager/ground_archive.gd" id="1_6q3u4"]
[ext_resource type="Script" uid="uid://cay6xv3x51phl" path="res://scene/ux/prop_inventory_resource.gd" id="2_sql7f"]
[ext_resource type="Script" uid="uid://cusw17h37oc0g" path="res://manager/archive_manager/assembled_archive.gd" id="3_qjj7j"]
[sub_resource type="Resource" id="Resource_6q3u4"]
script = ExtResource("1_6q3u4")
scene_name = "c02_s01"
data = {
"AnimationPlayer": {},
"Local寻人启事": {
"icount": 0
},
"Note门口": {
"icount": 2
},
"portal_left": {
"icount": 0
},
"portal_right": {
"icount": 1
}
}
[sub_resource type="Resource" id="Resource_qjj7j"]
script = ExtResource("1_6q3u4")
scene_name = "c02_s02"
data = {
"Ambush太暗了": {
"played": true
},
"Ambush小蝉闪现": {
"played": true
},
"Ambush推小鞋子": {
"played": true
},
"Ambush杂物堆": {
"played": true
},
"Ambush纸人": {
"played": false
},
"AnimationPlayer": {
"first_enter": true,
"flyer_shaven": true
},
"Closeup八音盒": {
"icount": 8,
"interacted_times": 8
},
"Closeup刮海报": {
"icount": 1,
"interacted_times": 1
},
"InteractableLeftPortal": {
"icount": 0,
"interacted_times": 0
},
"Interactable小鞋子": {
"icount": 1,
"interacted_times": 1
},
"Note大头娃娃": {
"icount": 0
},
"Note老鼠洞": {
"icount": 0
},
"portal_left": {
"icount": 0
},
"portal_right": {
"icount": 3
},
"海报1": {
"icount": 1
},
"煤油灯": {
"icount": 0,
"interacted_times": 0
},
"青岛啤酒": {
"icount": 1
},
"鼠疫海报": {
"icount": 0
}
}
[sub_resource type="Resource" id="Resource_ja7w6"]
script = ExtResource("1_6q3u4")
scene_name = "c02_s03"
data = {
"Ambush保卫科旁边os": {
"played": true
},
"Ambush偷听对话": {
"played": true
},
"Ambush惊悚闪电": {
"played": true
},
"Ambush点火游戏阻挡右移": {
"played": false
},
"Ambush等待的小蝉": {
"played": true
},
"Ambush老虎钳后小蝉跑": {
"played": true
},
"Ambush要下雨了": {
"played": true
},
"Ambush闷雷纸人": {
"played": true
},
"Ambush霸凌": {
"played": true
},
"AnimationPlayer": {},
"Closeup井": {
"icount": 1,
"interacted_times": 1
},
"Closeup抓药游戏": {
"icount": 0,
"interacted_times": 0
},
"Closeup敲门游戏": {
"icount": 1,
"interacted_times": 1
},
"Interactable柜子": {
"icount": 2,
"interacted_times": 2
},
"Interactable铁门": {
"icount": 1,
"interacted_times": 1
},
"Note柜子可放置区域": {
"icount": 0
},
"Note算命摊位": {
"icount": 0
},
"Note血迹": {
"icount": 0
},
"Npc井边疯子": {
"icount": 2
},
"Pickable小鞋子": {
"picked": true
},
"portal_1": {
"icount": 3
},
"portal_2": {
"icount": 4
},
"portal_3": {
"icount": 2
},
"portal_4": {
"icount": 0
},
"portal_5": {
"icount": 12
},
"portal_left": {
"icount": 2
},
"portal_right": {
"icount": 0
}
}
[sub_resource type="Resource" id="Resource_175lw"]
script = ExtResource("1_6q3u4")
scene_name = "c02_s04"
data = {
"Ambush进门看到小蝉": {
"played": true
},
"AnimationPlayer": {},
"Closeup折锡纸": {
"icount": 1,
"interacted_times": 1
},
"Closeup老鼠精": {
"icount": 0,
"interacted_times": 0
},
"Closeup花名册": {
"icount": 0,
"interacted_times": 0
},
"Local寻人启事": {
"icount": 0
},
"Pickable元宝": {
"picked": true
},
"portal_left": {
"icount": 3
},
"portal_right": {
"icount": 0
},
"小手讨东西": {
"holding_prop": "",
"icount": 5,
"is_holding_cup": false,
"tin_coin_drop": false
}
}
[sub_resource type="Resource" id="Resource_xnubk"]
script = ExtResource("1_6q3u4")
scene_name = "c02_s05"
data = {
"Ambush小蝉消失": {
"played": true
},
"Ambush更新一楼地图": {
"played": true
},
"Ambush杂物堆": {
"played": true
},
"Ambush老鼠叼肉": {
"played": false
},
"AnimationPlayer": {},
"Closeup戏台": {
"icount": 0,
"interacted_times": 0
},
"Closeup拿人偶后记忆闪回": {
"icount": 0,
"interacted_times": 0
},
"Interactable粘鼠板": {
"icount": 1,
"interacted_times": 1
},
"Note一楼内侧通道": {
"icount": 0
},
"Note垃圾通道": {
"icount": 0
},
"Note死老鼠": {
"icount": 0
},
"Npc疯子": {
"icount": 1
},
"Pickable掉落的肉": {
"picked": true
},
"portal_1": {
"icount": 3
},
"portal_2": {
"icount": 0
},
"portal_left": {
"icount": 5
},
"portal_right": {
"icount": 0
}
}
[sub_resource type="Resource" id="Resource_xfhwh"]
script = ExtResource("1_6q3u4")
scene_name = "c02_s06"
data = {
"Ambush三男孩": {
"played": true
},
"Ambush小蝉偷看": {
"played": true
},
"Ambush解锁二楼地图": {
"played": true
},
"Ambush走到尽头后小蝉跑": {
"played": false
},
"AnimationPlayer": {},
"Closeup弹珠游戏": {
"icount": 0,
"interacted_times": 0
},
"Closeup水盆": {
"icount": 0,
"interacted_times": 0
},
"Closeup谢幕演出": {
"icount": 0,
"interacted_times": 0
},
"Inspectable小猫纸条": {
"icount": 1
},
"Interactable老鼠洞": {
"icount": 2,
"interacted_times": 2
},
"Note楼梯杂物堆": {
"icount": 0
},
"portal_1": {
"icount": 0
},
"portal_2": {
"icount": 0
},
"portal_3": {
"icount": 1
},
"portal_4": {
"icount": 0
},
"portal_left": {
"icount": 3
},
"portal_right": {
"icount": 0
},
"煤油灯": {
"icount": 2,
"interacted_times": 1
}
}
[sub_resource type="Resource" id="Resource_53fcv"]
script = ExtResource("1_6q3u4")
scene_name = "c02_s07"
data = {
"AnimationPlayer": {
"flyer_shaven": true
},
"Closeup刮海报": {
"icount": 1,
"interacted_times": 1
},
"Note老鼠洞": {
"icount": 0
},
"Pickable元宝": {
"picked": false
},
"Pickable粘鼠板": {
"picked": true
},
"portal_1": {
"icount": 2
},
"portal_left": {
"icount": 0
},
"portal_right": {
"icount": 4
},
"小手讨东西": {
"holding_prop": "",
"icount": 2,
"is_holding_cup": true,
"tin_coin_drop": false
}
}
[sub_resource type="Resource" id="Resource_cio48"]
script = ExtResource("1_6q3u4")
scene_name = "c02_s08"
data = {
"Ambush偷听": {
"played": true
},
"Ambush回看洞口": {
"played": true
},
"AnimationPlayer": {
"first_entered": false,
"hole_interacted_times": 3
},
"Closeup画": {
"icount": 1,
"interacted_times": 1
},
"Closeup讨厌他们": {
"icount": 1,
"interacted_times": 1
},
"Note八卦图": {
"icount": 1
},
"Note十字架": {
"icount": 0
},
"portal_1": {
"icount": 1
},
"portal_left": {
"icount": 1
},
"portal_right": {
"icount": 0
},
"煤油灯": {
"icount": 1,
"interacted_times": 1
}
}
[sub_resource type="Resource" id="Resource_i3b22"]
script = ExtResource("1_6q3u4")
scene_name = "c02_s09"
data = {
"Ambush剪影指天": {
"played": true
},
"Ambush老鼠": {
"played": true
},
"AnimationPlayer": {},
"portal_left": {
"icount": 1
},
"portal_right": {
"icount": 0
}
}
[sub_resource type="Resource" id="Resource_cpr7i"]
script = ExtResource("1_6q3u4")
scene_name = "c02_s10"
data = {
"Ambush首次进入血脚印": {
"played": true
},
"AnimationPlayer": {
"display_wood_puppet": false
},
"Closeup头套": {
"icount": 1,
"interacted_times": 1
},
"Interactable小蝉": {
"icount": 1,
"interacted_times": 1
},
"Interactable柜子": {
"icount": 1,
"interacted_times": 1
},
"Note进入隧道询问": {
"icount": 1
},
"Pickable小猫玩具脑袋": {
"picked": true
},
"Pickable小蝉人偶": {
"picked": true
},
"portal_left": {
"icount": 1
},
"portal_right": {
"icount": 0
},
"煤油灯": {
"icount": 0,
"interacted_times": 1
}
}
[sub_resource type="Resource" id="Resource_06ncl"]
script = ExtResource("1_6q3u4")
scene_name = "c02_s12"
data = {
"AnimationPlayer": {},
"portal_left": {
"icount": 2
},
"portal_right": {
"icount": 0
}
}
[sub_resource type="Resource" id="Resource_xhybu"]
script = ExtResource("1_6q3u4")
scene_name = "c02_s13"
data = {
"Ambush猫咪敲门互动": {
"played": false
},
"Ambush猫咪敲门互动2": {
"played": false
},
"Ambush猫咪敲门互动3": {
"played": false
},
"Ambush终点光芒互动": {
"played": true
},
"AnimationPlayer": {},
"portal_1": {
"icount": 1
},
"portal_left": {
"icount": 0
},
"portal_right": {
"icount": 0
}
}
[sub_resource type="Resource" id="Resource_w25c8"]
script = ExtResource("1_6q3u4")
scene_name = "c02_s17"
data = {
"Ambush猫咪敲门互动2": {
"played": false
},
"AnimationPlayer": {},
"portal_1": {
"icount": 1
},
"portal_left": {
"icount": 0
},
"portal_right": {
"icount": 2
}
}
[sub_resource type="Resource" id="Resource_gujks"]
script = ExtResource("1_6q3u4")
scene_name = "c03_s01"
data = {
"Ambush偷听陆仁": {
"played": false
},
"AnimationPlayer": {},
"CloseupDemo公告": {
"icount": 1,
"interacted_times": 1
},
"Interactable放肉处": {
"icount": 1,
"interacted_times": 1
},
"Note治病木牌": {
"icount": 2
},
"portal_left": {
"icount": 0
},
"portal_right": {
"icount": 0
}
}
[sub_resource type="Resource" id="Resource_sql7f"]
script = ExtResource("2_sql7f")
owner = ""
current_index = 0
important_items = Array[String](["prop_眼镜盒", "prop_船票", "prop_单只小鞋子", "prop_小蝉人偶"])
unviewed_important_items = Array[String]([])
default_enabled_items = Array[String]([])
xdie_enabled_items = Array[String](["prop_火柴", "prop_撕下的照片上", "prop_小猫玩具完整", "prop_麻将"])
xxdie_enabled_items = Array[String]([])
xxxdie_enabled_items = Array[String]([])
[resource]
script = ExtResource("3_qjj7j")
version = 6
archive_id = 1
entrance_portal = &"right"
current_scene = &"c03_s01"
player_global_position_x = 616.833
player_direction = Vector2(-1, 0)
game_seconds = 1170
created_time = "2025-07-11 01:22:58"
event_stage = Dictionary[StringName, int]({
&"c02_2f_xchan_run_away": 1,
&"c02_ball_game_stage": 4,
&"c02_burning_end_stage": 2,
&"c02_got_pliers": 2,
&"c02_little_hand": 2,
&"c02_madman_interacted_stage": 2,
&"c02_musicbox_stage": 4,
&"c03_s01_meat_put": 1,
&"current_chapter_stage": 3,
&"handnote_c02_gate_chain": 2,
&"handnote_c02_hand_exchange": 2,
&"handnote_c02_hand_requireCoin": 2,
&"handnote_c02_map_1Left": 1,
&"handnote_c02_map_1Right": 1,
&"handnote_c02_map_2": 1,
&"handnote_c02_meat_knockDoor": 1,
&"handnote_c02_musicbox_needPuppet": 2,
&"handnote_c02_road_address": 1,
&"handnote_c02_shoe_giveToMouce": 2,
&"handnote_c02_split_illusionSpace": 1,
&"handnote_c02_xchan_giveHerPuppet": 2,
&"handnote_c02_xchan_sheKnowsMe": 2
})
global_data_dict = Dictionary[StringName, Variant]({
&"c02_boxcat_chasing_started": true,
&"c02_boxcat_skip_intro": true,
&"c02_burning": true,
&"c02_counter_pushed_out": true,
&"c02_counter_x": 159.0,
&"c02_eavesdrop_finished": true,
&"c02_entered_the_splitted_space": true,
&"c02_gaslight_first_failed": true,
&"c02_gaslight_first_lighted": true,
&"c02_little_hand_dropped_tin": true,
&"c02_madman_hitwall": true,
&"c02_meat_door_interacted": true,
&"c02_meat_dropping": true,
&"c02_meat_given": true,
&"c02_mouse_follow_player": false,
&"c02_musicbox_has_opened": false,
&"c02_musicbox_taken_prop_弹珠": true,
&"c02_musicbox_taken_prop_撕下的照片上": true,
&"c02_musicbox_taken_prop_无头小猫玩具": true,
&"c02_musicbox_taken_prop_木头人偶": true,
&"c02_ready_to_fire": true,
&"c02_s02_mouse_push_shoe": true,
&"c02_show_grounded_coins": true,
&"c02_the_blind_room_unlocked": true,
&"c02_tin_coin_taken": true,
&"c02_watched_the_well": true,
&"刮海报_二楼内侧": true,
&"刮海报_过道": true
})
ground_archives = Dictionary[StringName, ExtResource("1_6q3u4")]({
&"c02_s01": SubResource("Resource_6q3u4"),
&"c02_s02": SubResource("Resource_qjj7j"),
&"c02_s03": SubResource("Resource_ja7w6"),
&"c02_s04": SubResource("Resource_175lw"),
&"c02_s05": SubResource("Resource_xnubk"),
&"c02_s06": SubResource("Resource_xfhwh"),
&"c02_s07": SubResource("Resource_53fcv"),
&"c02_s08": SubResource("Resource_cio48"),
&"c02_s09": SubResource("Resource_i3b22"),
&"c02_s10": SubResource("Resource_cpr7i"),
&"c02_s12": SubResource("Resource_06ncl"),
&"c02_s13": SubResource("Resource_xhybu"),
&"c02_s17": SubResource("Resource_w25c8"),
&"c03_s01": SubResource("Resource_gujks")
})
npc_anonymous_states = Dictionary[StringName, bool]({})
player_running_locked = false
prop_inventory = SubResource("Resource_sql7f")
mem_display_dict = Dictionary[int, bool]({
1: true,
2: true
})
bayinhe_current_answer = [0, 0, 0, 0, 0, 0, 0, 0, 0]
bayinhe_mode = "closed"
metadata/_custom_type_script = "uid://cusw17h37oc0g"

View File

@ -16,6 +16,10 @@ func _ready() -> void:
# settings.exited.connect(grab_focus)
var debug_button_last_press_msec := 0
var debug_button_pressed_count := 0
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("escape"):
get_viewport().set_input_as_handled()
@ -33,6 +37,23 @@ func _unhandled_input(event: InputEvent) -> void:
elif event.is_action_pressed("panel"):
get_viewport().set_input_as_handled()
SceneManager.show_panel()
elif event.is_action_pressed("savings"):
if GlobalConfig.DEBUG:
get_viewport().set_input_as_handled()
SceneManager.show_savings()
else:
# 连续按 5 次开启 debug mode
var time = Time.get_ticks_msec()
if time - debug_button_last_press_msec < 500:
debug_button_pressed_count += 1
else:
debug_button_pressed_count = 1
debug_button_last_press_msec = time
if debug_button_pressed_count >= 5:
get_viewport().set_input_as_handled()
# 不写入配置
GlobalConfig.DEBUG = true
SceneManager.show_savings()
var vignette_tween: Tween
@ -48,6 +69,3 @@ func tween_vignette(ratio := 0.5, duration := 0.3, color := Color.RED):
vignette_tween.parallel().tween_property(
vignette.material, "shader_parameter/vignette_rgb", color, duration
)

View File

@ -1,68 +1,113 @@
extends Control
@onready var video_player = $VideoStreamPlayer
@onready var earplug_notice = $"耳机提示" as Control
# Constants
const EARPLUG_FADE_DURATION := 1.0
const EARPLUG_DISPLAY_DURATION := 3.0
@onready var sfx_click = $SfxClick as Sfx
@onready var settings = %"设置界面" as Control
@onready var lang_label = %LangLabel as Label
@onready var lang_left_btn = %LangLeft as Button
@onready var lang_right_btn = %LangRight as Button
@onready var caption_box = %CaptionBox as BoxContainer
@onready var caption_label = %CaptionLabel as Label
@onready var caption_left_btn = %CaptionLeft as Button
@onready var caption_right_btn = %CaptionRight as Button
@onready var confirm_btn = %ConfirmButton as Button
# Nodes
@onready var video_player := $VideoStreamPlayer
@onready var earplug_notice := $"耳机提示" as Control
@onready var sfx_click := $SfxClick as Sfx
@onready var settings := %"设置界面" as Control
@onready var lang_label := %LangLabel as Label
@onready var lang_left_btn := %LangLeft as Button
@onready var lang_right_btn := %LangRight as Button
@onready var caption_box := %CaptionBox as BoxContainer
@onready var caption_label := %CaptionLabel as Label
@onready var caption_left_btn := %CaptionLeft as Button
@onready var caption_right_btn := %CaptionRight as Button
@onready var confirm_btn := %ConfirmButton as Button
var packed_index_page := preload("uid://c4ycvdsabi7lw")
# State
var first_launching_game := true
var earplug_notice_tween: Tween
func _ready() -> void:
earplug_notice.hide()
if GlobalConfigManager.config:
var game_launched_times = GlobalConfigManager.config.game_launched_times
GlobalConfigManager.config.game_launched_times = game_launched_times + 1
first_launching_game = game_launched_times == 0
if GlobalConfigManager.config.skip_trailer:
SceneManager.checkout_index_page(false)
return
if not GlobalConfigManager.config:
SceneManager.checkout_index_page(false)
return
var game_launched_times = GlobalConfigManager.config.game_launched_times
GlobalConfigManager.config.game_launched_times += 1
first_launching_game = (game_launched_times == 0)
if GlobalConfigManager.config.skip_trailer:
SceneManager.checkout_index_page(false)
return
_setup_video_player()
_setup_language_settings(game_launched_times)
_connect_button_signals()
func _setup_video_player() -> void:
video_player.play()
video_player.finished.connect(_on_video_finished)
func _setup_language_settings(game_launched_times: int) -> void:
if game_launched_times == 0:
_read_system_locale()
_update_language_display()
func _update_language_display() -> void:
lang_label.text = GlobalConfigManager.get_locale_language_name()
caption_label.text = GlobalConfigManager.get_locale_caption_name()
if caption_label.text == "":
caption_box.hide()
lang_left_btn.pressed.connect(_on_lang_left_btn_pressed)
lang_right_btn.pressed.connect(_on_lang_right_btn_pressed)
caption_left_btn.pressed.connect(_on_caption_left_btn_pressed)
caption_right_btn.pressed.connect(_on_caption_right_btn_pressed)
caption_box.visible = (caption_label.text != "")
var earplug_notice_tween: Tween
func _connect_button_signals() -> void:
lang_left_btn.pressed.connect(_on_lang_change.bind(-1))
lang_right_btn.pressed.connect(_on_lang_change.bind(1))
caption_left_btn.pressed.connect(_on_caption_change.bind(-1))
caption_right_btn.pressed.connect(_on_caption_change.bind(1))
func _read_system_locale() -> void:
if not GlobalConfigManager.config:
return
var locale = OS.get_locale()
var prefix = locale.split("_")[0]
var config = GlobalConfigManager.config
if GlobalConfig.LOCALE_PREFIX_MAPPING.has(prefix):
config.language = GlobalConfig.LOCALE_PREFIX_MAPPING.get(prefix)
GlobalConfigManager.update_locale(config.language, config.caption)
print("[FirstLaunch] System locale: ", locale, " -> Language ID: ", GlobalConfigManager.config.language)
func _on_video_finished() -> void:
earplug_notice.visible = true
earplug_notice.modulate.a = 0
earplug_notice_tween = create_tween()
earplug_notice_tween.tween_property(earplug_notice, "modulate:a", 1.0, 1.0)
if not first_launching_game:
settings.hide()
earplug_notice_tween.tween_interval(3.0)
earplug_notice_tween.tween_property(earplug_notice, "modulate:a", 0.0, 1.0)
earplug_notice_tween.finished.connect(_on_earplug_notice_finished)
earplug_notice_tween.tween_property(earplug_notice, "modulate:a", 1.0, EARPLUG_FADE_DURATION)
if first_launching_game:
_show_first_launch_settings()
else:
settings.show()
confirm_btn.pressed.connect(_on_confirm_btn_pressed)
_show_regular_launch_sequence()
func _show_first_launch_settings() -> void:
settings.show()
confirm_btn.pressed.connect(_on_confirm_btn_pressed)
func _show_regular_launch_sequence() -> void:
settings.hide()
earplug_notice_tween.tween_interval(EARPLUG_DISPLAY_DURATION)
earplug_notice_tween.tween_property(earplug_notice, "modulate:a", 0.0, EARPLUG_FADE_DURATION)
earplug_notice_tween.finished.connect(_on_earplug_notice_finished)
func _on_confirm_btn_pressed() -> void:
confirm_btn.disabled = true
_fade_out_and_continue()
func _fade_out_and_continue() -> void:
var tween = create_tween()
tween.tween_property(earplug_notice, "modulate:a", 0.0, 1.0)
tween.tween_property(earplug_notice, "modulate:a", 0.0, EARPLUG_FADE_DURATION)
tween.finished.connect(_on_earplug_notice_finished)
@ -71,49 +116,37 @@ func _on_earplug_notice_finished() -> void:
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("escape") and not first_launching_game:
if video_player.is_playing():
get_viewport().set_input_as_handled()
video_player.stop()
_on_video_finished()
elif earplug_notice_tween and earplug_notice_tween.is_running():
get_viewport().set_input_as_handled()
earplug_notice_tween.kill()
earplug_notice.modulate.a = 0
_on_earplug_notice_finished()
if not event.is_action_pressed("escape") or first_launching_game:
return
if video_player.is_playing():
_skip_video()
elif earplug_notice_tween and earplug_notice_tween.is_running():
_skip_earplug_notice()
func _on_lang_left_btn_pressed() -> void:
func _skip_video() -> void:
get_viewport().set_input_as_handled()
video_player.stop()
_on_video_finished()
func _skip_earplug_notice() -> void:
get_viewport().set_input_as_handled()
earplug_notice_tween.kill()
earplug_notice.modulate.a = 0
_on_earplug_notice_finished()
func _on_lang_change(direction: int) -> void:
sfx_click.play()
GlobalConfigManager.update_locale(
GlobalConfigManager.config.language - 1, GlobalConfigManager.config.caption
)
lang_label.text = GlobalConfigManager.get_locale_language_name()
caption_label.text = GlobalConfigManager.get_locale_caption_name()
caption_box.visible = caption_label.text != ""
var config = GlobalConfigManager.config
GlobalConfigManager.update_locale(config.language + direction, config.caption)
_update_language_display()
func _on_lang_right_btn_pressed() -> void:
func _on_caption_change(direction: int) -> void:
sfx_click.play()
GlobalConfigManager.update_locale(
GlobalConfigManager.config.language + 1, GlobalConfigManager.config.caption
)
lang_label.text = GlobalConfigManager.get_locale_language_name()
caption_label.text = GlobalConfigManager.get_locale_caption_name()
caption_box.visible = caption_label.text != ""
func _on_caption_left_btn_pressed() -> void:
sfx_click.play()
GlobalConfigManager.update_locale(
GlobalConfigManager.config.language, GlobalConfigManager.config.caption - 1
)
caption_label.text = GlobalConfigManager.get_locale_caption_name()
func _on_caption_right_btn_pressed() -> void:
sfx_click.play()
GlobalConfigManager.update_locale(
GlobalConfigManager.config.language, GlobalConfigManager.config.caption + 1
)
caption_label.text = GlobalConfigManager.get_locale_caption_name()
var config = GlobalConfigManager.config
GlobalConfigManager.update_locale(config.language, config.caption + direction)
caption_label.text = GlobalConfigManager.get_locale_caption_name()

View File

@ -10,6 +10,7 @@ const TWEEN_DURATION = 0.5
const SHAKE_FPS = 15.0
const SHAKE_DURATION = 0.8
const SHAKE_DELTA = 12.0
const HUD_FADE_DURATION = 0.3
@export_group("DebugItem")
@export var enable_item := false:
@ -63,6 +64,7 @@ var listen_mouse = false
var displaying = false
var timer := Timer.new()
var display_tween: Tween
var hud_visibility_tween: Tween
var container_tween: Tween
var prop_blink: Tween
var tween_scroll: Tween
@ -174,11 +176,6 @@ func checkout_inventory(character: String) -> void:
_reload_cache_and_realign_display()
const HUD_FADE_DURATION = 0.3
# 在变量定义区域添加
var hud_visibility_tween: Tween
func hide_hud(duration: float = HUD_FADE_DURATION):
if not visible:
return