Reported from the game: sounds sometimes start a bit late. The samples were the suspect, so they were measured first - and they are clean. True digital silence at the head of each file: seven of nine start on sample 1 sfx_gas_blast 0.2ms sfx_black_hole 1.5ms, then a deliberate swell The black hole's slow attack is the sound it was written to be - make_sfx.py says it "swells across most of its length instead of decaying from the first sample" - so there is nothing to trim without breaking it. The delay was in the playback path, and "sometimes" was the clue: play() called load() every time. A cache hit after the first, but the FIRST play of each sound was a synchronous read and decode on the frame the sound was asked for. Ten sounds, ten occasional late starts. _pool() built its twelve AudioStreamPlayer nodes lazily, on the first play() call - one bigger stall, every session. The catalog is preloaded now, so play() never touches the disk, and Sfx.warm() builds the voices next to Music.ensure() at startup. Case F pins the thing that was originally suspected, since make_sfx.py regenerates these and a lazy envelope would hide the delay somewhere nobody would look. It reads the source .wav's RIFF chunks rather than the imported stream: these import as QOA (compress/mode=2), so AudioStreamWAV.data is not PCM. The first version of that check was vacuous for exactly that reason and passed at a 0.5ms threshold; it now reports "9 samples checked, worst attack sfx_black_hole.wav at 1.5ms" and fails when tightened. Not touched: project.godot has no [audio] section, so output_latency is at its default. On Android that buffer is usually the dominant latency, but lowering it risks crackle on real hardware - worth doing with someone listening, not blind. 172/172. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
212 lines
8.2 KiB
GDScript
212 lines
8.2 KiB
GDScript
extends Node2D
|
|
|
|
# The menu stacks whatever entries are actually available, rather than
|
|
# trusting the positions authored in the scene.
|
|
#
|
|
# Two of the three entries only exist when the game is run from Godot, so a
|
|
# shipped build shows one entry and an editor run shows three. There is no
|
|
# Quit: a web build has nothing to quit TO, and on Android the system back
|
|
# gesture already does it. Fixed
|
|
# offsets would leave holes where the hidden entries used to be - the same
|
|
# failure the HUD's button columns had, where a literal y meant "below the
|
|
# thing above me" and stopped being true when that thing moved. Here the
|
|
# stack is measured instead, and centred on whatever it ends up containing.
|
|
|
|
const ROW_BUTTON_HEIGHT: float = 128.0
|
|
const ROW_LABEL_OFFSET: float = 134.0
|
|
const ROW_LABEL_HEIGHT: float = 56.0
|
|
const ROW_HEIGHT: float = ROW_LABEL_OFFSET + ROW_LABEL_HEIGHT
|
|
const ROW_GAP: float = 20.0
|
|
const ROW_PITCH: float = ROW_HEIGHT + ROW_GAP
|
|
# The stack is centred on this rather than on the screen, to leave the title
|
|
# above it room to breathe.
|
|
const STACK_CENTER_Y: float = 40.0
|
|
const STATUS_GAP: float = 20.0
|
|
const STATUS_HEIGHT: float = 70.0
|
|
|
|
@onready var play_button: IconButton = $UI/PlayButton
|
|
@onready var play_label: Label = $UI/PlayLabel
|
|
@onready var editor_button: IconButton = $UI/EditorButton
|
|
@onready var editor_label: Label = $UI/EditorLabel
|
|
@onready var dev_button: IconButton = $UI/DevModeButton
|
|
@onready var dev_label: Label = $UI/DevModeLabel
|
|
@onready var status_label: Label = $UI/StatusLabel
|
|
|
|
# #169: what is being opened, and the cover that says so. Empty path means
|
|
# nothing is loading, which is also what stops a second press starting a
|
|
# second scene - on the web a button fires on PRESS (see IconButton), and an
|
|
# impatient double tap on Play would otherwise build the world twice.
|
|
const LOADING_SCREEN = preload("res://ui/LoadingScreen.gd")
|
|
# How the player is told what they are waiting for. Keyed by scene path so a
|
|
# new entry cannot go in without a name to put on the screen.
|
|
const SCENE_NAMES: Dictionary = {
|
|
"res://world/Main.tscn": "Entering the convoy lane",
|
|
"res://surface/PlanetSetupEditor.tscn": "Opening the map editor",
|
|
}
|
|
# The load only starts once the cover has been up for a frame or two. One
|
|
# would do; two is cheap and removes any argument about whether the first one
|
|
# reached the screen.
|
|
const COVER_FRAMES: int = 2
|
|
|
|
var loading_path: String = ""
|
|
var loading_screen: Control = null
|
|
|
|
|
|
func _ready() -> void:
|
|
# #148: the theme starts here, at the first screen, and keeps playing
|
|
# through every scene after it.
|
|
Music.ensure(get_tree())
|
|
# Same moment, same reason: build the sfx voices before the first
|
|
# sound needs them rather than during it.
|
|
Sfx.warm(get_tree())
|
|
play_button.pressed.connect(_on_play_pressed)
|
|
editor_button.pressed.connect(_on_editor_pressed)
|
|
editor_button.enabled = true
|
|
dev_button.pressed.connect(_on_dev_mode_pressed)
|
|
dev_button.enabled = true
|
|
|
|
# Dev mode and the map editor are development tools, not features of the
|
|
# game: they are for building and play-testing it, and a player who
|
|
# installed a build has no use for either. `editor` is the feature tag
|
|
# Godot sets when running from the editor and clears in an export, which
|
|
# is exactly the "started from Godot" distinction wanted here.
|
|
var developing: bool = is_developer_build()
|
|
editor_button.visible = developing
|
|
editor_label.visible = developing
|
|
dev_button.visible = developing
|
|
dev_label.visible = developing
|
|
|
|
_layout_stack()
|
|
|
|
|
|
# Wrapped so tests can state the rule they are checking rather than
|
|
# duplicating the feature-tag string.
|
|
func is_developer_build() -> bool:
|
|
return OS.has_feature("editor")
|
|
|
|
|
|
func _layout_stack() -> void:
|
|
var rows: Array = []
|
|
for row in [[play_button, play_label], [editor_button, editor_label],
|
|
[dev_button, dev_label]]:
|
|
if row[0].visible:
|
|
rows.append(row)
|
|
if rows.is_empty():
|
|
return
|
|
|
|
# The last row carries no trailing gap, so the block is one pitch per row
|
|
# minus that gap.
|
|
var block_height: float = float(rows.size()) * ROW_PITCH - ROW_GAP
|
|
var top: float = STACK_CENTER_Y - block_height * 0.5
|
|
for row in rows:
|
|
var button: Control = row[0]
|
|
var label: Label = row[1]
|
|
button.offset_top = top
|
|
button.offset_bottom = top + ROW_BUTTON_HEIGHT
|
|
label.offset_top = top + ROW_LABEL_OFFSET
|
|
label.offset_bottom = top + ROW_LABEL_OFFSET + ROW_LABEL_HEIGHT
|
|
top += ROW_PITCH
|
|
|
|
# Status sits under the whole stack, where a message about any entry can
|
|
# be read without it shifting the entries themselves.
|
|
var status_top: float = top - ROW_GAP + STATUS_GAP
|
|
status_label.offset_top = status_top
|
|
status_label.offset_bottom = status_top + STATUS_HEIGHT
|
|
|
|
|
|
func _on_play_pressed() -> void:
|
|
# Checked here as well as in _start: this one has a side effect, and a
|
|
# second press while the first is still loading must not wipe the game
|
|
# that first press already set up (#169).
|
|
if loading_path != "":
|
|
return
|
|
GameState.reset_new_game()
|
|
_start("res://world/Main.tscn")
|
|
|
|
|
|
# Loaded, built and added by hand rather than through change_scene_to_file().
|
|
# That call does all three in one opaque step, so when the web build hung
|
|
# there was no way to tell which part failed. Split out, each step can say so
|
|
# - and a scene change that fails otherwise leaves the menu up, which looks
|
|
# exactly like an unpressed button.
|
|
#
|
|
# #169: and now it does not look like anything else either. The press raises a
|
|
# loading screen straight away and the load itself happens over the following
|
|
# frames, driven from _process below, so the wait is something the player is
|
|
# watching rather than a menu that has stopped responding.
|
|
func _start(scene_path: String) -> void:
|
|
if loading_path != "":
|
|
return
|
|
loading_path = scene_path
|
|
status_label.text = ""
|
|
loading_screen = LOADING_SCREEN.new()
|
|
$UI.add_child(loading_screen)
|
|
loading_screen.begin(String(SCENE_NAMES.get(scene_path, scene_path.get_file())))
|
|
|
|
|
|
# The load itself is still the ordinary blocking one, deliberately.
|
|
#
|
|
# ResourceLoader.load_threaded_request() was the obvious alternative and it
|
|
# does not work here: asked for these scenes it comes back FAILED, with the
|
|
# text-scene parser reporting errors ("Parse Error ... Rock.tscn:9", "Could
|
|
# not resolve external class member PROFILES") that the same files do not
|
|
# produce through load(). The project's scripts and scenes reach each other
|
|
# through global class names and preloads, and that resolution is not
|
|
# available off the loading thread. A progress bar is not worth a menu that
|
|
# sometimes refuses to start the game - and the web build could not have used
|
|
# the threaded path anyway, since it ships with thread support off.
|
|
#
|
|
# So what the cover buys is honest and worth having on its own: the press is
|
|
# answered instantly, the player is told what is opening, and the frame that
|
|
# blocks happens with something on the screen rather than behind a menu that
|
|
# has stopped responding.
|
|
func _process(_delta: float) -> void:
|
|
if loading_path == "":
|
|
return
|
|
# Nothing blocking until the cover is actually up - see LoadingScreen's
|
|
# own note. This is the whole reason the load waits for a frame at all.
|
|
if loading_screen == null or loading_screen.frames_shown < COVER_FRAMES:
|
|
return
|
|
var scene_path: String = loading_path
|
|
loading_path = ""
|
|
var packed: PackedScene = load(scene_path)
|
|
if packed == null:
|
|
_abandon_loading("Could not load %s" % scene_path.get_file())
|
|
return
|
|
var scene: Node = packed.instantiate()
|
|
if scene == null:
|
|
_abandon_loading("Could not build %s" % scene_path.get_file())
|
|
return
|
|
get_tree().root.add_child(scene)
|
|
get_tree().current_scene = scene
|
|
queue_free()
|
|
|
|
|
|
# A failed load must leave a working menu behind, not a cover over a screen
|
|
# with nothing coming.
|
|
func _abandon_loading(message: String) -> void:
|
|
if loading_screen != null:
|
|
loading_screen.queue_free()
|
|
loading_screen = null
|
|
status_label.text = message
|
|
|
|
|
|
func _on_dev_mode_pressed() -> void:
|
|
if loading_path != "":
|
|
return
|
|
# Guarded as well as hidden. Visibility is presentation; this is the rule.
|
|
if not is_developer_build():
|
|
status_label.text = "Dev mode only runs from Godot"
|
|
return
|
|
GameState.start_dev_game()
|
|
_start("res://world/Main.tscn")
|
|
|
|
|
|
func _on_editor_pressed() -> void:
|
|
if not is_developer_build():
|
|
status_label.text = "Map editor only runs from Godot"
|
|
return
|
|
if editor_button.enabled:
|
|
_start("res://surface/PlanetSetupEditor.tscn")
|
|
return
|
|
status_label.text = "Map editor coming soon"
|