Feat/fidelity pass 2 #23

Merged
Dotts merged 7 commits from feat/fidelity-pass-2 into main 2026-07-28 10:31:27 -07:00
22 changed files with 1121 additions and 552 deletions
Showing only changes of commit 2efc21b18d - Show all commits
Binary file not shown.

After

Width:  |  Height:  |  Size: 611 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 765 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 690 KiB

+1 -1
View File
@@ -51,7 +51,7 @@ func _process(_delta: float) -> bool:
sm.set_active_skin(_skin)
var nm = root.get_node_or_null("NetworkManager")
if nm and nm.has_method("start_singleplayer_match"):
nm.start_singleplayer_match("Deathmatch")
nm.start_singleplayer_match(GameMode.DEATHMATCH)
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
return false
if _frames < 160:
+1 -1
View File
@@ -41,7 +41,7 @@ func _process(_delta: float) -> bool:
if _frames == 40:
var nm = root.get_node_or_null("NetworkManager")
if nm and nm.has_method("start_singleplayer_match"):
nm.start_singleplayer_match("Deathmatch")
nm.start_singleplayer_match(GameMode.DEATHMATCH)
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
elif _frames >= 160:
for p in root.find_children("*", "CharacterBody3D", true, false):
+1
View File
@@ -0,0 +1 @@
uid://cjpesfuv0vmcs
+201
View File
@@ -0,0 +1,201 @@
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
box = vi.global_transform * box
if not any:
out = box
any = true
else:
out = out.merge(box)
return out if any else AABB()
+1
View File
@@ -0,0 +1 @@
uid://esvuwcyncpeb
+65
View File
@@ -0,0 +1,65 @@
extends SceneTree
## One photograph of every main-menu screen, including the lobby.
##
## godot --path . --windowed --resolution 1280x720 \
## -s res://debug/menu_capture.gd -- <out_dir>
##
## The lobby is the reason this exists. It only appears after hosting, so it is
## the screen least likely to be looked at and the one where an unstyled list or
## a collapsed card would sit unnoticed — which is exactly the class of defect
## that this project keeps finding by LOOKING rather than by asserting.
const MENU := "res://ui/main_menu/main_menu.tscn"
var _out := "."
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if args.size() > 0:
_out = String(args[0])
_run()
func _run() -> void:
await process_frame
change_scene_to_file(MENU)
for _i in 60:
await process_frame
var menu := current_scene
if menu == null:
print("menu_capture: no menu")
quit(1)
return
await _shot("home")
menu._show(menu.LEVELS)
await _shot("levels")
menu._show(menu.MULTIPLAYER)
await _shot("multiplayer")
# Host for real, so the lobby is photographed in the state a player reaches
# it in — host controls enabled, own name in the list — rather than in an
# empty one that would hide a broken player list.
var nm = root.get_node_or_null("NetworkManager")
if nm and nm.host_game() == OK:
menu._on_connection_succeeded()
for _i in 20:
await process_frame
await _shot("lobby")
nm.disconnect_game()
else:
print("menu_capture: could not host, lobby not shot")
quit(0)
func _shot(tag: String) -> void:
for _i in 12:
await process_frame
root.get_texture().get_image().save_png("%s/menu_%s.png" % [_out, tag])
print("menu_capture: saved ", tag)
+1 -1
View File
@@ -25,7 +25,7 @@ func _process(_delta: float) -> bool:
sm.set_active_skin("taila")
var nm = root.get_node_or_null("NetworkManager")
if nm and nm.has_method("start_singleplayer_match"):
nm.start_singleplayer_match("Deathmatch")
nm.start_singleplayer_match(GameMode.DEATHMATCH)
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
elif _frames == 160:
for p in root.find_children("*", "CharacterBody3D", true, false):
+1 -1
View File
@@ -30,7 +30,7 @@ func _initialize() -> void:
_out_dir = args[0]
var nm = root.get_node_or_null("NetworkManager")
if nm and nm.has_method("start_singleplayer_match"):
nm.start_singleplayer_match("Deathmatch")
nm.start_singleplayer_match(GameMode.DEATHMATCH)
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
+1 -1
View File
@@ -56,7 +56,7 @@ func _test_spawn_with_skin(skin_id: String, expect_skinned: bool) -> void:
var nm = root.get_node_or_null("/root/NetworkManager")
if not _check(nm != null, "NetworkManager autoload exists"):
return
nm.start_singleplayer_match("Deathmatch")
nm.start_singleplayer_match(GameMode.DEATHMATCH)
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
# Let the level build and the player spawn + settle.
+1
View File
@@ -0,0 +1 @@
uid://bnw4gima0hkli
+1 -1
View File
@@ -35,7 +35,7 @@ func _process(_delta: float) -> bool:
# rather than a flat colour — that its legibility has to survive.
var nm = root.get_node_or_null("NetworkManager")
if nm and nm.has_method("start_singleplayer_match"):
nm.start_singleplayer_match("Deathmatch")
nm.start_singleplayer_match(GameMode.DEATHMATCH)
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
200:
_open("_show_main_menu")
+1 -1
View File
@@ -26,7 +26,7 @@ func _process(_delta: float) -> bool:
_shot("menu")
var nm = root.get_node_or_null("NetworkManager")
if nm and nm.has_method("start_singleplayer_match"):
nm.start_singleplayer_match("Deathmatch")
nm.start_singleplayer_match(GameMode.DEATHMATCH)
change_scene_to_file(_scene)
elif _frames == 240:
_shot("level")
+7 -1
View File
@@ -85,7 +85,13 @@ func start_singleplayer_match(gamemode: String = GameMode.DEATHMATCH) -> void:
connected_players.clear()
connected_players.append(1)
current_gamemode = gamemode
# Normalised, because `current_gamemode` used to be a DISPLAY string
# ("Deathmatch") and half a dozen callers still pass one. GameMode's lookups
# all fall back safely, but an id that is not in the table would compare
# unequal to GameMode.GUN_GAME and silently disable Gun Game's weapon
# issuing — a mode that half works is worse than one that does not load.
current_gamemode = gamemode if GameMode.MODES.has(gamemode) \
else GameMode.DEATHMATCH
player_stats.clear()
_init_player_stats(1, SettingsManager.username)
+144
View File
@@ -0,0 +1,144 @@
extends Button
class_name LevelCard
## One map on the level select: its photograph, its name, and its mode.
##
## The cards used to be a two-stop gradient generated from `color1` and `color2`
## in the map's meta file. That tells a player which card they clicked last time
## and nothing at all about the map, which is the entire job of a level select —
## and the hover state was `use_hdr = true` on the gradient texture, which is not
## a visible change on any of them.
##
## The preview comes from assets/ui/map_previews/<folder>.png, shot by
## debug/map_preview_capture.gd from inside the map. A map with no preview falls
## back to its gradient, so nothing breaks for a map that has not been
## photographed yet.
##
## Built on Button rather than TextureButton so it gets focus, keyboard
## navigation and the theme's state machinery for free. The image sits inside the
## button's border rather than under it, so the border reads as a frame around a
## photograph instead of a rectangle behind one.
const INSET := 5
var _plate: ColorRect
var _label: Label
var _mode_label: Label
func setup(title: String, preview_path: String, gradient: Gradient,
card_size: Vector2) -> void:
custom_minimum_size = card_size
clip_contents = true
# The frame. Papaya at rest, volt on hover, and the fill stays transparent
# so the photograph is what the player sees.
var clear := Color(0, 0, 0, 0)
add_theme_stylebox_override("normal", _frame(clear, UITheme.PAPAYA, 3))
add_theme_stylebox_override("hover", _frame(clear, UITheme.VOLT, 5))
add_theme_stylebox_override("pressed", _frame(
Color(UITheme.VOLT.r, UITheme.VOLT.g, UITheme.VOLT.b, 0.25),
UITheme.VOLT, 5))
add_theme_stylebox_override("focus", _frame(clear, UITheme.CYAN, 5))
add_theme_stylebox_override("disabled", _frame(
Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, 0.7),
UITheme.DEAD_EDGE, 3))
if ResourceLoader.exists(preview_path):
var shot := TextureRect.new()
shot.texture = load(preview_path)
shot.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
# COVERED, not scaled: a photograph letterboxed inside a card reads as a
# thumbnail in a file browser. Filling the card and cropping reads as a
# poster, which is what this is.
shot.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
shot.mouse_filter = Control.MOUSE_FILTER_IGNORE
_fill(shot)
add_child(shot)
else:
var grad := TextureRect.new()
var tex := GradientTexture2D.new()
tex.gradient = gradient
tex.width = int(card_size.x)
tex.height = int(card_size.y)
tex.fill_to = Vector2(1, 1)
grad.texture = tex
grad.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
grad.stretch_mode = TextureRect.STRETCH_SCALE
grad.mouse_filter = Control.MOUSE_FILTER_IGNORE
_fill(grad)
add_child(grad)
# The name plate: a hard ink band across the bottom rather than a gradient
# fade, because the theme is drawn and a soft fade is not.
_plate = ColorRect.new()
_plate.color = Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, 0.88)
_plate.mouse_filter = Control.MOUSE_FILTER_IGNORE
_plate.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_WIDE)
_plate.offset_top = -62
_plate.offset_left = INSET
_plate.offset_right = -INSET
_plate.offset_bottom = -INSET
add_child(_plate)
var stack := VBoxContainer.new()
stack.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
stack.add_theme_constant_override("separation", -2)
stack.mouse_filter = Control.MOUSE_FILTER_IGNORE
_plate.add_child(stack)
_label = Label.new()
_label.text = title
_label.add_theme_font_size_override("font_size", 28)
_label.add_theme_color_override("font_color", UITheme.PAPER)
_label.add_theme_color_override("font_outline_color", UITheme.INK)
_label.add_theme_constant_override("outline_size", 6)
_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
stack.add_child(_label)
_mode_label = Label.new()
_mode_label.add_theme_font_size_override("font_size", 16)
_mode_label.add_theme_color_override("font_color", UITheme.PAPER_DIM)
_mode_label.add_theme_color_override("font_outline_color", UITheme.INK)
_mode_label.add_theme_constant_override("outline_size", 4)
_mode_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
stack.add_child(_mode_label)
# The label follows the frame, same rule as every other control: hover turns
# the frame volt, so the name turns volt with it.
mouse_entered.connect(func(): _tint(UITheme.VOLT))
mouse_exited.connect(func(): _tint(UITheme.PAPER))
focus_entered.connect(func(): _tint(UITheme.CYAN))
focus_exited.connect(func(): _tint(UITheme.PAPER))
## What pressing this card will start. Shown under the name so the player is
## never guessing which mode the button they are about to press launches.
func set_mode_text(text: String) -> void:
if _mode_label:
_mode_label.text = text
func _tint(c: Color) -> void:
if _label:
_label.add_theme_color_override("font_color", c)
func _fill(c: Control) -> void:
c.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
c.offset_left = INSET
c.offset_top = INSET
c.offset_right = -INSET
c.offset_bottom = -INSET
func _frame(fill: Color, border: Color, width: int) -> StyleBoxFlat:
var sb := StyleBoxFlat.new()
sb.bg_color = fill
sb.border_color = border
sb.set_border_width_all(width)
sb.set_corner_radius_all(5)
sb.shadow_color = Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, 0.6)
sb.shadow_size = 4
sb.shadow_offset = Vector2(5, 5)
return sb
+1
View File
@@ -0,0 +1 @@
uid://2bfw74vxcitf
+669 -532
View File
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -369,8 +369,20 @@ static func _button_look(t: Theme, cls: String) -> void:
t.set_color("font_hover_pressed_color", cls, ink_for(VOLT))
t.set_color("font_focus_color", cls, PAPER)
t.set_color("font_disabled_color", cls, DEAD_TEXT)
# NO OUTLINE on a button label.
#
# An outline exists to separate a glyph from a backdrop the glyph cannot beat
# on its own — over the 3D scene, or across a two-tone progress bar. A button
# label has neither problem: it sits on a solid chip, and `ink_for` has
# already given it a colour that beats that chip.
#
# Worse, the theme can only carry ONE outline colour per class, and half the
# states here use an ink glyph. Ink glyphs inside a 5 px ink outline are not
# outlined, they are five pixels fatter — the selected mode chip rendered as
# an unreadable dark blob on volt. The chips keep their heavy ink BORDER, so
# the drawn look is unaffected.
t.set_color("font_outline_color", cls, INK)
t.set_constant("outline_size", cls, 5)
t.set_constant("outline_size", cls, 0)
# The icon has to invert with the label. A paper glyph on a volt chip is the
# same 1.1:1 the text would have been, and a CheckBox is ALL icon.
t.set_color("icon_normal_color", cls, PAPER)