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/.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 box = vi.global_transform * box if not any: out = box any = true else: out = out.merge(box) return out if any else AABB()