210 lines
7.5 KiB
GDScript
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()
|