Randoloot/scripts/levels/system_generator.gd

120 lines
5.0 KiB
GDScript

extends RefCounted
const BALANCE := preload("res://scripts/levels/level_balance.gd")
const NAMES := preload("res://scripts/levels/celestial_names.gd")
const ALIEN_SHIPS := preload("res://scripts/items/alien_ship_catalog.gd")
const WORLD_SIZE := Vector2(5200, 3600)
const PLAYER_START := Vector2(180, 200)
const SAFE_ZONE_RADIUS := 1200.0
const ENEMY_MIN_SEPARATION := 420.0
const CELESTIAL_TEXTURES := preload("res://scripts/levels/celestial_texture_catalog.gd")
const STAR_TEXTURES: Array[Texture2D] = CELESTIAL_TEXTURES.STAR_TEXTURES
const PLANET_TEXTURES: Array[Texture2D] = CELESTIAL_TEXTURES.PLANET_TEXTURES
static func generate(level: int, seed_value: int) -> Dictionary:
var rng := RandomNumberGenerator.new()
rng.seed = seed_value
# Separate stream keeps names stable without changing existing system geometry.
var name_rng := RandomNumberGenerator.new()
name_rng.seed = seed_value ^ 0x4e414d45
var texture_rng := RandomNumberGenerator.new()
texture_rng.seed = seed_value ^ 0x54455854
var planet_names := NAMES.PLANETS.duplicate()
var star_names := NAMES.STARS.duplicate()
var star_name := NAMES.take_name(star_names, name_rng)
# Keep layout random draws independent of the expanded asset catalog.
rng.randi_range(0, 7)
var star_texture := STAR_TEXTURES[texture_rng.randi_range(0, STAR_TEXTURES.size() - 1)]
var planet_count := rng.randi_range(1, 5)
var protected_index := rng.randi_range(0, planet_count - 1)
var angle_offset := rng.randf_range(0.0, TAU)
var planets: Array[Dictionary] = []
for index in planet_count:
var angle := angle_offset + TAU * index / planet_count + rng.randf_range(-0.12, 0.12)
var orbit := 1000.0 + 110.0 * index + rng.randf_range(-40.0, 40.0)
var planet_radius := rng.randf_range(70.0, 105.0)
rng.randi_range(0, 17)
planets.append({
"name": NAMES.take_name(planet_names, name_rng),
"position": Vector2.RIGHT.rotated(angle) * orbit,
"radius": planet_radius,
"texture": PLANET_TEXTURES[texture_rng.randi_range(0, PLANET_TEXTURES.size() - 1)],
"protected": index == protected_index,
})
var profiles := preload("res://scripts/planets/planet_profiles.gd")
var earthlike := profiles.earthlike_textures()
for index in planets.size():
if bool(planets[index].protected):
planets[index].texture = earthlike[texture_rng.randi_range(0, earthlike.size() - 1)]
planets[index].earthlike = profiles.is_earthlike(planets[index].texture)
planets[index].profile = profiles.generate(seed_value, index, planets[index].name) if bool(planets[index].earthlike) else {}
var enemies: Array[Dictionary] = []
var ship_rng := RandomNumberGenerator.new()
ship_rng.seed = seed_value ^ (level * 0x53484950)
var enemy_texture_index := ship_rng.randi_range(0, ALIEN_SHIPS.TEXTURES.size() - 1)
var tiers := BALANCE.enemy_tiers(level)
# Spread ships across the playable area instead of packing them around planets.
for tier in tiers:
var position := _find_enemy_position(planets, enemies, rng)
enemies.append({
"position": position,
"tier": tier,
"texture_index": enemy_texture_index,
})
return {
"enemy_texture_index": enemy_texture_index,
"star_name": star_name,
"star_texture": star_texture,
"star_position": Vector2.ZERO,
"player_start": PLAYER_START,
"planets": planets,
"protected_index": protected_index,
"enemies": enemies,
}
static func _find_enemy_position(planets: Array[Dictionary], enemies: Array[Dictionary], rng: RandomNumberGenerator) -> Vector2:
var best := Vector2.ZERO
var best_distance := -1.0
var valid_samples := 0
# Prefer the clearest of several random positions to avoid accidental clusters.
for attempt in 3000:
var candidate := Vector2(rng.randf_range(-2380, 2380), rng.randf_range(-1580, 1580))
if not _is_valid_enemy_position(candidate, planets, enemies):
continue
if enemies.is_empty():
return candidate
var nearest := INF
for enemy in enemies:
nearest = minf(nearest, candidate.distance_squared_to(enemy["position"]))
if nearest > best_distance:
best_distance = nearest
best = candidate
valid_samples += 1
if valid_samples >= 16:
return best
if best_distance >= 0.0:
return best
# Deterministic fallback still respects spacing, planets and the starting area.
for x in range(-2280, 2281, 130):
for y in range(-1500, 1501, 130):
var candidate := Vector2(x, y)
if _is_valid_enemy_position(candidate, planets, enemies):
return candidate
push_error("Could not place an enemy outside the safe zone.")
return Vector2(2380, 1580)
static func _is_valid_enemy_position(candidate: Vector2, planets: Array[Dictionary], enemies: Array[Dictionary]) -> bool:
if absf(candidate.x) > 2380 or absf(candidate.y) > 1580:
return false
if candidate.length() < SAFE_ZONE_RADIUS or candidate.distance_to(PLAYER_START) < 940.0:
return false
for planet in planets:
var center: Vector2 = planet["position"]
if candidate.distance_to(center) < float(planet["radius"]) + 75.0:
return false
for enemy in enemies:
var center: Vector2 = enemy["position"]
if candidate.distance_to(center) < ENEMY_MIN_SEPARATION:
return false
return true