Feat/fidelity pass 2 #23
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 |
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://cjpesfuv0vmcs
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://esvuwcyncpeb
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://bnw4gima0hkli
|
||||
+1
-1
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://2bfw74vxcitf
|
||||
+700
-563
File diff suppressed because it is too large
Load Diff
+13
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user