Files
Papay-Shooter/debug/map_preview_capture.gd
2026-08-02 02:20:02 -04:00

210 lines
7.5 KiB
GDScript

extends SceneTree
## Photograph every map, for the level select cards.
##
## godot --path . --windowed --resolution 640x400 \
## -s res://debug/map_preview_capture.gd
##
## Writes assets/ui/map_previews/<folder>.png, which the menu picks up by
## convention — no map_meta key to add, so a new map that ships a preview gets
## one and a map that does not falls back to its gradient.
##
## The cards used to be a two-stop gradient built from `color1` and `color2` in
## the map's meta file. That tells a player which card they clicked last time and
## nothing whatsoever about the map, which is the entire job of a level select.
##
## ── Framing ─────────────────────────────────────────────────────────────────
##
## The camera stands INSIDE the map, at a spawn point, at eye height, looking
## across it.
##
## The first version framed the whole level from outside by merging every
## VisualInstance3D's AABB and backing off until it fit. Every map came out as a
## tiny diorama floating on a table in the middle of an empty sky — and one came
## out entirely black, because from orbit there was nothing lit in frame. That is
## a minimap, and a minimap is a different thing from a photograph.
##
## What a player wants off a card is the FEEL of a place: its light, its colour,
## how enclosed it is, what the skyline looks like. All of that only exists from
## where a player will actually stand, so that is where the camera goes — and
## spawn points are already authored in every map, so it costs nothing.
const OUT_DIR := "res://assets/ui/map_previews"
const MAPS_DIR := "res://scenes/maps/"
## How long to let a map build itself. The procedural ones generate geometry in
## `_ready` and the neon map streams in props, so a shot taken too early is a
## photograph of an empty skybox.
const SETTLE_FRAMES := 90
func _initialize() -> void:
_run()
func _run() -> void:
await process_frame
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(OUT_DIR))
for folder in _map_folders():
var cfg := ConfigFile.new()
if cfg.load(MAPS_DIR + folder + "/map_meta.cfg") != OK:
continue
var scene_path: String = cfg.get_value("map", "scene_path", "")
if scene_path == "" or not ResourceLoader.exists(scene_path):
continue
var packed: PackedScene = load(scene_path)
if packed == null:
print("map_preview: could not load ", scene_path)
continue
var level: Node = packed.instantiate()
root.add_child(level)
for _i in SETTLE_FRAMES:
await process_frame
var shot: Image = await _best_shot(level)
var path := "%s/%s.png" % [OUT_DIR, folder]
if shot == null:
print("map_preview: no usable vantage in ", folder)
else:
shot.save_png(ProjectSettings.globalize_path(path))
print("map_preview: saved ", path)
level.queue_free()
for _i in 6:
await process_frame
quit(0)
func _map_folders() -> Array:
var out: Array = []
var dir := DirAccess.open(MAPS_DIR)
if dir == null:
return out
dir.list_dir_begin()
var name := dir.get_next()
while name != "":
if dir.current_is_dir() and not name.begins_with("."):
if FileAccess.file_exists(MAPS_DIR + name + "/map_meta.cfg"):
out.append(name)
name = dir.get_next()
out.sort()
return out
## Try several vantages and keep the one that produces the best PICTURE.
##
## Not the one that satisfies a rule about where cameras should go. Two maps
## defeated every positional rule tried: fps_blockout is a genuinely dark map
## where most spawns face an unlit wall, and procedural_arena builds its geometry
## at runtime so no fixed offset is inside it. Both came out as black rectangles,
## and a black rectangle passes any check that asks "did the camera end up
## somewhere sensible".
##
## So the tool renders each candidate and SCORES the result. The score wants an
## image that is both bright and varied: mean luminance alone picks a shot of the
## empty sky, and variance alone picks a high-contrast corner of a dark room.
## Their product picks a photograph.
func _best_shot(level: Node):
var bounds := _bounds(level)
var centre := bounds.get_center() if bounds.size.length() > 0.01 else Vector3.ZERO
var extent: float = maxf(maxf(bounds.size.x, bounds.size.z), 8.0)
var cam := Camera3D.new()
level.add_child(cam)
cam.far = maxf(bounds.size.length() * 2.0, 800.0)
# A little wider than the game's own view, so a card shows more of the space
# than a screenshot of play would.
cam.fov = 68.0
cam.current = true
var best_img: Image = null
var best_score := -1.0
for vantage in _vantages(centre, extent):
cam.global_position = vantage[0]
var look: Vector3 = vantage[1]
if cam.global_position.distance_to(look) < 1.0:
continue
cam.look_at(look)
# Two frames: one for the transform, one for the frame drawn with it.
for _i in 3:
await process_frame
var img := root.get_texture().get_image()
var score := _score(img)
if score > best_score:
best_score = score
best_img = img
cam.queue_free()
return best_img
## Candidate camera placements, as `[eye, look_at]` pairs.
##
## Every authored spawn point, plus a raised view from each side of the map for
## the levels that generate themselves and have no spawns by the time this runs.
func _vantages(centre: Vector3, extent: float) -> Array:
var out: Array = []
for s in get_nodes_in_group("spawn_points"):
if s is Node3D:
var p: Vector3 = (s as Node3D).global_position
out.append([p + Vector3.UP * 2.2, centre + Vector3.UP * 1.6])
# Raised corners, looking in. Higher than eye level, because a generated
# arena's floor is not necessarily at the origin and standing "on" it is a
# guess where looking down at it is not.
for dir in [Vector3(1, 0, 1), Vector3(-1, 0, 1), Vector3(1, 0, -1),
Vector3(-1, 0, -1)]:
var d: Vector3 = dir.normalized()
out.append([centre + d * extent * 0.42 + Vector3.UP * extent * 0.22,
centre + Vector3.UP * extent * 0.04])
return out
## How good a picture is: bright AND varied. See `_best_shot`.
func _score(img: Image) -> float:
var small := img.duplicate() as Image
small.resize(40, 24, Image.INTERPOLATE_BILINEAR)
var n := 40 * 24
var mean := 0.0
for y in 24:
for x in 40:
var c := small.get_pixel(x, y)
mean += 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b
mean /= float(n)
var variance := 0.0
for y in 24:
for x in 40:
var c := small.get_pixel(x, y)
var l := 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b
variance += (l - mean) * (l - mean)
variance /= float(n)
return mean * sqrt(variance)
func _bounds(node: Node) -> AABB:
var out := AABB()
var any := false
for child in node.find_children("*", "VisualInstance3D", true, false):
var vi := child as VisualInstance3D
# Skip anything enormous: a WorldEnvironment's sky or a directional
# light's own AABB would swallow the map and push the camera to orbit.
var box := vi.get_aabb()
if box.size.length() > 100000.0 or box.size.length() < 0.001:
continue
# Skip declared BACKDROP: distant hills, ridges and anything else that
# is painted scenery rather than level. Sakura Crossing's far ridge sits
# 380 m out, which inflated the merged bounds to about a kilometre and
# put every fallback vantage in orbit — the map photographed as a
# diorama floating in an empty sky, which is the exact failure this
# tool's framing was rewritten to avoid.
if vi.has_meta("preview_backdrop"):
continue
box = vi.global_transform * box
if not any:
out = box
any = true
else:
out = out.merge(box)
return out if any else AABB()