spaceship/surface/PlanetSetups.gd
Loïc Wisniewski 2f161621c3 #84: move 215 root files into a folder hierarchy
Everything lived in one flat directory, so a diff gave no clue what area it
touched and finding the file you wanted meant knowing its exact name. Twelve
folders now, grouped by what a thing IS rather than what type it is - a
vessel's definition, its component scenes and its upgrade resources sit
together, because that is the set you read when you change one of them.

  buildings  core  controllers  enemies  logistics  planets
  resources  shaders  surface  ui  vessels  vfx  world

332 files moved and every res:// reference rewritten - 306 files touched,
including the autoload and main-scene paths in project.godot. Nothing renamed:
this is purely where files sit, so the diff is a pile of pure moves and no
behaviour is buried in it. ui_theme.tres, icon.svg and web_shell.html stay at
the root, where Godot and the export preset expect them.

Verified three ways, because a suite alone would not have caught all of it:
the full 117 pass, the web export builds clean, and the game renders.

The export is why that matters. The FIRST export after the move failed with
"Attempt to open script 'res://GameState.gd'" - the old path - while
project.godot on disk plainly said res://core/GameState.gd. That is this
project's documented Android FUSE mtime trap: Godot served a cached copy of a
file whose content had changed. A `touch` fixed it. Nothing in the test suite
would have found that, because the suite runs from a copy on a native
filesystem - the one place the bug cannot happen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:44:14 +02:00

143 lines
5.8 KiB
GDScript

class_name PlanetSetups
extends RefCounted
# Registry of authored PlanetSetup resources. Deliberately scans
# res://PlanetSetups/ at first use instead of a hardcoded preload
# dictionary (contrast PlanetDefinitions.gd), so dropping a new .tres in
# that folder is the only step needed to add a setup - the map editor
# (#48) writes there directly.
const SETUPS_DIR: String = "res://PlanetSetups"
# Compared against get_script() rather than an `is PlanetSetup` check - the
# same load-order hazard as PlanetSurfaceController's setup_host: a global
# class_name identifier can be looked up before an import pass has
# registered it, while a direct script preload never depends on that cache.
const PLANET_SETUP_SCRIPT: Script = preload("res://surface/PlanetSetup.gd")
static var _all: Array = []
static var _scanned: bool = false
# Which setup each planet drew, once it has drawn one. See setup_for_planet.
static var _assigned: Dictionary = {}
static func _ensure_scanned() -> void:
if _scanned:
return
_scanned = true
var dir: DirAccess = DirAccess.open(SETUPS_DIR)
if dir == null:
return
dir.list_dir_begin()
var file_name: String = dir.get_next()
while file_name != "":
if not dir.current_is_dir() and file_name.ends_with(".tres"):
# CACHE_MODE_REPLACE, not a plain load(). Godot caches resources by
# path, so after the map editor saved a setup this handed back the
# CACHED copy - still holding the values from before the save. The
# editor called rescan() and the pool dutifully re-served the stale
# data, which is what "I changed the research points and it was not
# saved" actually was: the file on disk was correct all along.
#
# REPLACE rather than IGNORE deliberately: it refreshes the
# existing instance in place instead of minting a new one, so
# object identity across a rescan survives - which the per-planet
# map assignments depend on (see setup_for_planet).
var res: Resource = ResourceLoader.load(SETUPS_DIR + "/" + file_name,
"", ResourceLoader.CACHE_MODE_REPLACE)
if res != null and res.get_script() == PLANET_SETUP_SCRIPT:
_all.append(res)
file_name = dir.get_next()
dir.list_dir_end()
static func rescan() -> void:
# The map editor calls this after saving so a freshly written setup is
# visible without restarting the process.
_all = []
_scanned = false
_ensure_scanned()
# Assignments are PRUNED, not cleared. Clearing them would undo the whole
# point of remembering: saving one map in the editor would silently re-deal
# every planet the player had already visited. What has to go is only the
# entries pointing at a setup that no longer exists - a deleted map must
# not stay pinned to a planet.
var live: Dictionary = {}
for setup in _all:
live[setup] = true
for key in _assigned.keys():
if not live.has(_assigned[key]):
_assigned.erase(key)
static func all() -> Array:
_ensure_scanned()
return _all
static func find_by_id(setup_id: String) -> Resource:
for setup in all():
if String(setup.setup_id) == setup_id:
return setup
return null
static func path_for_id(setup_id: String) -> String:
# The file a setup actually LIVES in, which is not reliably
# "PlanetSetups/<setup_id>.tres". Authored resources carry whatever
# filename they were created with, and at least one shipped setup has an
# id that differs from its filename outright (ResearchLabSetup.tres holds
# setup_id "research_lab_1"). Deriving the path from the id instead of
# asking the resource broke two things at once: deleting such a setup
# looked for a file that does not exist and silently refused, and saving
# one wrote a SECOND file under the derived name, leaving the original
# behind with the old contents - which reads exactly like "my change was
# not saved", because the stale twin is still in the pool.
var setup: Resource = find_by_id(setup_id)
if setup != null and String(setup.resource_path) != "":
return String(setup.resource_path)
return "%s/%s.tres" % [SETUPS_DIR, setup_id]
static func setups_for_profile(profile_id: String) -> Array:
var result: Array = []
for setup in all():
if setup.surface_profile_id == profile_id:
result.append(setup)
# Sorted by id, because the order they arrive in is whatever order the
# filesystem listed them in - which is not something to seed a choice
# with. The pick below indexes into this array, so an unstable order
# means the same planet can land on a different map from one run to the
# next, and the "this is MY planet's layout" illusion goes with it.
result.sort_custom(func(a, b): return String(a.setup_id) < String(b.setup_id))
return result
static func setup_for_planet(planet_id: String, profile_id: String) -> Resource:
# Every planet gets a map of its own type, drawn at random, and keeps it.
#
# The draw is seeded purely from the planet's own stable id - same pattern
# as WorldGen.planet_data_at_chunk - so leaving a planet and coming back
# lands on the same layout without anything having to be saved.
#
# The result is then remembered anyway, because the seed alone is not
# quite enough: the pool it indexes into changes the moment the map editor
# saves a new setup, which would silently re-deal every planet in the
# middle of a session. Once a planet has been visited its map is pinned
# for as long as the process lives.
#
# Keyed on the profile too, not the planet alone: the memo must never be
# able to hand back a gas map for a rocky query just because that planet
# id was seen first.
var key: String = "%s|%s" % [planet_id, profile_id]
var remembered: Resource = _assigned.get(key)
if remembered != null:
return remembered
var candidates: Array = setups_for_profile(profile_id)
if candidates.is_empty():
return null
var rng: RandomNumberGenerator = RandomNumberGenerator.new()
rng.seed = hash(planet_id)
var chosen: Resource = candidates[rng.randi() % candidates.size()]
_assigned[key] = chosen
return chosen