Rework the UI on Crusenho's pack; credit it in the game, not just the repo
ci / verify (push) Successful in 48s
ci / verify (push) Successful in 48s
Crusenho's Complete UI Essential Pack is CC BY 4.0 -- redistributable and
commercial-friendly, confirmed from the License.txt the pack itself ships --
so unlike the two bdragon packs a subset is committed: twelve PNGs, 48 KB,
under assets/sprites/ui/. Only what is used, because each committed PNG costs
a Godot .import sidecar and a directory nothing references is one nobody
prunes.
UiTheme builds a Theme in code from it -- button states, panels, line edits --
and every screen roots itself through UiTheme.themed_root(). The HUD's bars are
the pack's frame with a tinted fill, drawn as three horizontal slices because
Godot's nine-patch lives on nodes and the HUD is drawn rather than built from
controls. Inventory slots use the pack's slot art at exactly twice the source
size; a non-integer scale on a 1px border reads as a wobble along every edge.
The credits screen is the other half of the request and it is a licence
obligation, not a nicety: two packs are now CC BY, which asks for attribution
"in any reasonable manner", and a markdown file in a source repo is not
reasonable for someone who downloaded a build. Settings -> Credits shows every
source with its terms and a link to the licence text. test_credits.gd asserts
CREDITS.md and docs/ASSETS.md name every entry, so the three cannot drift.
Two things found by actually looking at the screen, which is the point:
- The FIRST version of this styled nothing. A Control inherits its theme from
Control ANCESTORS only, and the chain breaks at the first plain Node or
CanvasLayer -- which is every screen here. get_window().theme set the
property, changed nothing, and read as correct. check.sh, 458 tests and a
clean smoke run all passed with the entire interface unstyled. The theme
test now instantiates every screen and asks what its buttons resolve.
- The settings screen showed Fire bound to the right mouse button, because
the test suite was writing the player's real user://settings.cfg --
rebinding calls save() and nothing had redirected the path. Settings.path
is now redirectable, the fixture points it at a scratch file, and a test
asserts the default is still the player's own.
tools/screenshot.tscn is what found both. It boots the client windowed and
saves the menus, the HUD, settings and credits. Manual, needs a display, and
the only thing in the project that can tell you the interface rendered.
check.sh clean, 460 tests, SMOKE PASS, all four diagnostics green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
class_name Credits
|
||||
extends RefCounted
|
||||
## Every third-party asset the game ships, and the terms it ships under.
|
||||
##
|
||||
## This is not decoration. Two of these packs are CC BY 4.0, which requires
|
||||
## attribution "in any reasonable manner" -- a file in the repository is not
|
||||
## reasonable for a player who downloaded a build, so the game has to say so
|
||||
## itself. See [CreditsScreen].
|
||||
##
|
||||
## `tests/unit/test_credits.gd` asserts every entry here also appears in
|
||||
## CREDITS.md, so the two cannot drift apart.
|
||||
|
||||
## Each entry: what it is, who made it, the licence, a link, and what it is
|
||||
## used for. `required` marks the ones the licence obliges us to show.
|
||||
const SOURCES: Array[Dictionary] = [
|
||||
{
|
||||
"name": "16x16 DungeonTileset II",
|
||||
"author": "0x72 (Robert Norenberg)",
|
||||
"licence": "CC0 1.0 (public domain)",
|
||||
"url": "https://0x72.itch.io/dungeontileset-ii",
|
||||
"used_for": "Terrain, characters, bosses, item icons",
|
||||
"required": false,
|
||||
},
|
||||
{
|
||||
"name": "Complete UI Essential Pack",
|
||||
"author": "Crusenho Agus Hennihuno",
|
||||
"licence": "CC BY 4.0",
|
||||
"url": "https://crusenho.itch.io/complete-ui-essential-pack",
|
||||
"used_for": "Buttons, panels, bars, inventory slots",
|
||||
"required": true,
|
||||
},
|
||||
{
|
||||
"name": "Pixel Combat SFX",
|
||||
"author": "Helton Yan",
|
||||
"licence": "CC BY 4.0",
|
||||
"url": "https://heltonyan.itch.io/pixelcombat",
|
||||
"used_for": "All sound effects",
|
||||
"required": true,
|
||||
},
|
||||
{
|
||||
"name": "Fire Pixel Bullet 16x16",
|
||||
"author": "bdragon1727",
|
||||
"licence": "Custom - not redistributable",
|
||||
"url": "https://bdragon1727.itch.io/fire-pixel-bullet-16x16",
|
||||
"used_for": "Bullet sprites (local builds only)",
|
||||
"required": false,
|
||||
},
|
||||
{
|
||||
"name": "750+ Effect and FX Pixel All",
|
||||
"author": "bdragon1727",
|
||||
"licence": "Custom - not redistributable",
|
||||
"url": "https://bdragon1727.itch.io/750-effect-and-fx-pixel-all",
|
||||
"used_for": "Impact effects (local builds only)",
|
||||
"required": false,
|
||||
},
|
||||
]
|
||||
|
||||
## CC BY asks for a link to the licence itself, not only to the work.
|
||||
const LICENCE_LINKS := {
|
||||
"CC BY 4.0": "https://creativecommons.org/licenses/by/4.0/",
|
||||
"CC0 1.0 (public domain)": "https://creativecommons.org/publicdomain/zero/1.0/",
|
||||
}
|
||||
|
||||
|
||||
## The packs whose licence actually obliges us to credit them. Everything else
|
||||
## in [constant SOURCES] is there because the work deserves it.
|
||||
static func required() -> Array[Dictionary]:
|
||||
var out: Array[Dictionary] = []
|
||||
for entry in SOURCES:
|
||||
if bool(entry["required"]):
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
static func licence_url(licence: String) -> String:
|
||||
return String(LICENCE_LINKS.get(licence, ""))
|
||||
@@ -0,0 +1 @@
|
||||
uid://drpadmmsrvoax
|
||||
+11
-4
@@ -10,7 +10,14 @@ extends RefCounted
|
||||
## tool has no main loop and cannot resolve autoload names, and the settings
|
||||
## file is something a headless test may well want to read.
|
||||
|
||||
const PATH := "user://settings.cfg"
|
||||
const DEFAULT_PATH := "user://settings.cfg"
|
||||
|
||||
## Where preferences are read from and written to. A variable rather than a
|
||||
## constant so tests can point it at a scratch file: rebinding calls save(),
|
||||
## and a suite run was otherwise rewriting the player's real settings -- which
|
||||
## it did, silently, until a screenshot showed Fire bound to the right mouse
|
||||
## button.
|
||||
static var path: String = DEFAULT_PATH
|
||||
|
||||
## Actions the settings screen offers, in the order it lists them. Anything not
|
||||
## here keeps whatever `tools/setup_input_map.gd` gave it and cannot be changed
|
||||
@@ -54,7 +61,7 @@ static var _defaults_captured: bool = false
|
||||
static func load_and_apply() -> void:
|
||||
_capture_defaults()
|
||||
var cfg := ConfigFile.new()
|
||||
if cfg.load(PATH) == OK:
|
||||
if cfg.load(path) == OK:
|
||||
master_volume = clampf(float(cfg.get_value("audio", "master", master_volume)), 0.0, 1.0)
|
||||
sfx_volume = clampf(float(cfg.get_value("audio", "sfx", sfx_volume)), 0.0, 1.0)
|
||||
bindings.clear()
|
||||
@@ -81,9 +88,9 @@ static func save() -> void:
|
||||
cfg.set_value("audio", "sfx", sfx_volume)
|
||||
for action in bindings:
|
||||
cfg.set_value("input", action, bindings[action])
|
||||
var err := cfg.save(PATH)
|
||||
var err := cfg.save(path)
|
||||
if err != OK:
|
||||
GameLog.warn("settings", "could not write %s (error %d)" % [PATH, err])
|
||||
GameLog.warn("settings", "could not write %s (error %d)" % [path, err])
|
||||
|
||||
|
||||
# --- Audio -------------------------------------------------------------------
|
||||
|
||||
+5
-1
@@ -1,4 +1,4 @@
|
||||
[gd_scene load_steps=10 format=3]
|
||||
[gd_scene load_steps=11 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://src/view/game_scene.gd" id="1"]
|
||||
[ext_resource type="Script" path="res://src/view/world_view.gd" id="2"]
|
||||
@@ -9,6 +9,7 @@
|
||||
[ext_resource type="Script" path="res://src/ui/character_select.gd" id="7"]
|
||||
[ext_resource type="Script" path="res://src/ui/upgrade_screen.gd" id="8"]
|
||||
[ext_resource type="Script" path="res://src/ui/settings_screen.gd" id="9"]
|
||||
[ext_resource type="Script" path="res://src/ui/credits_screen.gd" id="10"]
|
||||
|
||||
[node name="Game" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
@@ -37,3 +38,6 @@ script = ExtResource("8")
|
||||
|
||||
[node name="SettingsScreen" type="CanvasLayer" parent="."]
|
||||
script = ExtResource("9")
|
||||
|
||||
[node name="CreditsScreen" type="CanvasLayer" parent="."]
|
||||
script = ExtResource("10")
|
||||
|
||||
+10
@@ -11,6 +11,7 @@ const CONNECT_TIMEOUT_SEC := 8.0
|
||||
|
||||
var _menu: Control = null
|
||||
var _settings: CanvasLayer = null
|
||||
var _credits: CanvasLayer = null
|
||||
var _game: Node = null
|
||||
var _ticks: int = 0
|
||||
var _connecting: bool = false
|
||||
@@ -151,10 +152,19 @@ func _open_settings() -> void:
|
||||
if _settings == null:
|
||||
_settings = preload("res://src/ui/settings_screen.gd").new()
|
||||
_settings.closed.connect(func() -> void: _settings.visible = false)
|
||||
_settings.credits_requested.connect(_open_credits)
|
||||
add_child(_settings)
|
||||
_settings.open()
|
||||
|
||||
|
||||
func _open_credits() -> void:
|
||||
if _credits == null:
|
||||
_credits = preload("res://src/ui/credits_screen.gd").new()
|
||||
_credits.closed.connect(func() -> void: _credits.visible = false)
|
||||
add_child(_credits)
|
||||
_credits.open()
|
||||
|
||||
|
||||
func _clear_game() -> void:
|
||||
if _game != null:
|
||||
_game.queue_free()
|
||||
|
||||
@@ -24,8 +24,7 @@ var _selected: String = ""
|
||||
func _ready() -> void:
|
||||
layer = 30
|
||||
_rng.randomize()
|
||||
var root := Control.new()
|
||||
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
var root := UiTheme.themed_root()
|
||||
add_child(root)
|
||||
|
||||
var scrim := ColorRect.new()
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
extends CanvasLayer
|
||||
## Attribution, in the game rather than only in the repository.
|
||||
##
|
||||
## CC BY 4.0 requires credit "in any reasonable manner", and a markdown file
|
||||
## nobody who downloads a build will ever see does not clear that bar. Two of
|
||||
## the packs the game ships are CC BY, so this screen is a licence obligation
|
||||
## and not a nicety -- which is why its contents come from [Credits] and a test
|
||||
## asserts nothing in there is missing from it.
|
||||
|
||||
signal closed
|
||||
|
||||
var _list: VBoxContainer
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
layer = 34
|
||||
visible = false
|
||||
var root := UiTheme.themed_root()
|
||||
add_child(root)
|
||||
|
||||
var scrim := ColorRect.new()
|
||||
scrim.color = Color(0.03, 0.03, 0.06, 0.95)
|
||||
scrim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
scrim.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
root.add_child(scrim)
|
||||
|
||||
var centre := CenterContainer.new()
|
||||
centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
root.add_child(centre)
|
||||
|
||||
var panel := VBoxContainer.new()
|
||||
panel.custom_minimum_size = Vector2(620.0, 0.0)
|
||||
panel.add_theme_constant_override("separation", 8)
|
||||
centre.add_child(panel)
|
||||
|
||||
var title := Label.new()
|
||||
title.text = "CREDITS"
|
||||
title.add_theme_font_size_override("font_size", 26)
|
||||
panel.add_child(title)
|
||||
|
||||
var intro := Label.new()
|
||||
intro.text = "Third-party work this game is built from. " \
|
||||
+ "Credited whether or not the licence asks."
|
||||
intro.add_theme_font_size_override("font_size", 12)
|
||||
intro.add_theme_color_override("font_color", Color(0.6, 0.65, 0.78))
|
||||
intro.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
intro.custom_minimum_size = Vector2(620.0, 0.0)
|
||||
panel.add_child(intro)
|
||||
|
||||
var scroll := ScrollContainer.new()
|
||||
scroll.custom_minimum_size = Vector2(620.0, 340.0)
|
||||
panel.add_child(scroll)
|
||||
_list = VBoxContainer.new()
|
||||
_list.custom_minimum_size = Vector2(600.0, 0.0)
|
||||
_list.add_theme_constant_override("separation", 12)
|
||||
scroll.add_child(_list)
|
||||
for entry in Credits.SOURCES:
|
||||
_add_entry(entry)
|
||||
|
||||
var close := Button.new()
|
||||
close.text = "Close"
|
||||
close.custom_minimum_size = Vector2(160.0, 34.0)
|
||||
close.pressed.connect(func() -> void: closed.emit())
|
||||
panel.add_child(close)
|
||||
|
||||
|
||||
func _add_entry(entry: Dictionary) -> void:
|
||||
var block := VBoxContainer.new()
|
||||
block.add_theme_constant_override("separation", 1)
|
||||
_list.add_child(block)
|
||||
|
||||
var heading := Label.new()
|
||||
heading.text = "%s — %s" % [entry["name"], entry["author"]]
|
||||
heading.add_theme_font_size_override("font_size", 16)
|
||||
block.add_child(heading)
|
||||
|
||||
var detail := Label.new()
|
||||
var licence := String(entry["licence"])
|
||||
var link := Credits.licence_url(licence)
|
||||
# The licence LINK, not just its name: CC BY asks for one, and a player
|
||||
# reading this screen has no other way to reach the terms.
|
||||
detail.text = "%s · %s%s\n%s" % [
|
||||
entry["used_for"], licence,
|
||||
" · " + link if not link.is_empty() else "",
|
||||
entry["url"]]
|
||||
detail.add_theme_font_size_override("font_size", 12)
|
||||
detail.add_theme_color_override("font_color", Color(0.62, 0.67, 0.8))
|
||||
detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
detail.custom_minimum_size = Vector2(600.0, 0.0)
|
||||
block.add_child(detail)
|
||||
|
||||
|
||||
func open() -> void:
|
||||
visible = true
|
||||
@@ -0,0 +1 @@
|
||||
uid://bo3xikvfahke5
|
||||
+1
-2
@@ -23,8 +23,7 @@ var _open: bool = false
|
||||
|
||||
func _ready() -> void:
|
||||
layer = 20
|
||||
var root := Control.new()
|
||||
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
var root := UiTheme.themed_root()
|
||||
add_child(root)
|
||||
|
||||
# Dim the game behind the menu so it is obvious the world is still running.
|
||||
|
||||
+73
-20
@@ -6,8 +6,17 @@ const MARGIN := 24.0
|
||||
const BAR_W := 260.0
|
||||
const BAR_H := 16.0
|
||||
## Inventory slot box, and the gap between boxes.
|
||||
const SLOT := 46.0
|
||||
##
|
||||
## Exactly twice the 32px source sprite. A non-integer scale on pixel art with
|
||||
## nearest filtering gives uneven pixel widths, which on a 1px border reads as
|
||||
## a wobble along the edge of every slot.
|
||||
const SLOT := 64.0
|
||||
const SLOT_GAP := 8.0
|
||||
## Source-pixel width of the caps on the bar sprite. The bar is stretched only
|
||||
## horizontally, so the ends are drawn at their own size and the middle takes
|
||||
## whatever is left -- a plain stretched draw would smear a 1px border out to
|
||||
## eight.
|
||||
const BAR_CAP := 6.0
|
||||
## How far above the bottom of the screen the inventory row sits.
|
||||
const SLOT_BOTTOM := 26.0
|
||||
|
||||
@@ -31,6 +40,7 @@ func _ready() -> void:
|
||||
# and the death-message centering down with it, since all three read
|
||||
# _canvas.size. set_anchors_and_offsets_preset() sets both correctly.
|
||||
_canvas.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
UiTheme.apply_to(_canvas)
|
||||
_canvas.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_canvas.draw.connect(_draw_hud)
|
||||
add_child(_canvas)
|
||||
@@ -44,7 +54,10 @@ func _ready() -> void:
|
||||
# here means "56px above the bottom anchor", which is what "-56" was meant
|
||||
# to say in the first place.
|
||||
_hint.offset_left = MARGIN
|
||||
_hint.offset_top = -56.0
|
||||
# Above the inventory row, not through it. Derived from the row's own
|
||||
# geometry rather than written down, because the slots grew from 46px to 64
|
||||
# and a hardcoded -56 put the control hints straight across them.
|
||||
_hint.offset_top = -(SLOT_BOTTOM + SLOT + 34.0)
|
||||
|
||||
# A real Button rather than something drawn in _draw_hud: this is the one
|
||||
# HUD element the player has to actually click, and it starts disabled so
|
||||
@@ -126,13 +139,13 @@ func _draw_hud() -> void:
|
||||
var origin := Vector2(MARGIN, MARGIN + 28.0)
|
||||
_bar(origin, float(client.my_hp) / float(maxi(client.my_max_hp, 1)),
|
||||
Color(0.35, 0.9, 0.6), Color(0.1, 0.15, 0.18))
|
||||
_draw_xp_bar(origin + Vector2(0.0, BAR_H + 3.0))
|
||||
_draw_xp_bar(origin + Vector2(0.0, BAR_H + 4.0))
|
||||
|
||||
if client.my_escaping:
|
||||
_bar(origin + Vector2(0.0, BAR_H + 8.0), client.my_escape,
|
||||
_bar(origin + Vector2(0.0, (BAR_H + 4.0) * 2.0), client.my_escape,
|
||||
Color(0.5, 0.85, 1.0), Color(0.1, 0.15, 0.2))
|
||||
_canvas.draw_string(ThemeDB.fallback_font,
|
||||
origin + Vector2(BAR_W + 12.0, BAR_H + 8.0 + BAR_H),
|
||||
origin + Vector2(BAR_W + 12.0, (BAR_H + 4.0) * 2.0 + BAR_H - 3.0),
|
||||
"ESCAPING", HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(0.5, 0.85, 1.0))
|
||||
|
||||
_draw_boss_bar()
|
||||
@@ -195,15 +208,15 @@ func _draw_inventory() -> void:
|
||||
var at := origin + Vector2(float(i) * (SLOT + SLOT_GAP), 0.0)
|
||||
var item := Items.by_index(int(client.my_inventory[i]))
|
||||
var def := Items.get_def(item)
|
||||
var frame_col := Color(0.55, 0.6, 0.72, 0.75) if i == held \
|
||||
else Color(0.3, 0.33, 0.42, 0.6)
|
||||
_canvas.draw_rect(Rect2(at, Vector2(SLOT, SLOT)), Color(0.07, 0.08, 0.12, 0.72))
|
||||
_canvas.draw_rect(Rect2(at, Vector2(SLOT, SLOT)), frame_col, false, 1.5)
|
||||
# The held slot uses the pack's pressed-in slot art rather than a
|
||||
# recoloured border, so "this is the one the key is on" reads the same
|
||||
# way every other pressed thing in the interface does.
|
||||
var slot_art := UiTheme.texture("slot_active" if i == held else "slot")
|
||||
_canvas.draw_texture_rect(slot_art, Rect2(at, Vector2(SLOT, SLOT)), false)
|
||||
# The slot number, because the key that uses it is the only thing the
|
||||
# player actually needs to know about a slot.
|
||||
_canvas.draw_string(ThemeDB.fallback_font, at + Vector2(4.0, 13.0),
|
||||
str(i + 1), HORIZONTAL_ALIGNMENT_LEFT, -1, 11,
|
||||
Color(0.5, 0.55, 0.68))
|
||||
_canvas.draw_string(ThemeDB.fallback_font, at + Vector2(6.0, 17.0),
|
||||
str(i + 1), HORIZONTAL_ALIGNMENT_LEFT, -1, 12, UiTheme.INK_DIM)
|
||||
if def == null:
|
||||
continue
|
||||
var icon := Art.item_icon(item)
|
||||
@@ -248,13 +261,12 @@ func _draw_xp_bar(at: Vector2) -> void:
|
||||
var capped := level >= Progression.MAX_LEVEL
|
||||
var progress := Progression.level_progress(client.my_total_xp)
|
||||
var tint := Color(1.0, 0.85, 0.4) if capped else Color(0.6, 0.55, 1.0)
|
||||
_canvas.draw_rect(Rect2(at, Vector2(BAR_W, 5.0)), Color(0.1, 0.12, 0.18))
|
||||
_canvas.draw_rect(Rect2(at, Vector2(BAR_W * progress, 5.0)), tint)
|
||||
_bar(at, progress, tint, Color.BLACK)
|
||||
# The number as well as the bar: "how far to the next level" is a question
|
||||
# a bar answers vaguely and a percentage answers exactly.
|
||||
var text := "MAX" if capped else "%d%% to level %d" % [
|
||||
int(floor(progress * 100.0)), level + 1]
|
||||
_canvas.draw_string(ThemeDB.fallback_font, at + Vector2(BAR_W + 10.0, 6.0),
|
||||
_canvas.draw_string(ThemeDB.fallback_font, at + Vector2(BAR_W + 10.0, 13.0),
|
||||
text, HORIZONTAL_ALIGNMENT_LEFT, -1, 12, tint)
|
||||
|
||||
|
||||
@@ -299,8 +311,13 @@ func _draw_boss_bar() -> void:
|
||||
var w := 560.0
|
||||
var pos := Vector2((_canvas.size.x - w) * 0.5, MARGIN)
|
||||
var frac := clampf(float(b["hp"]) / float(client.boss_def.max_hp), 0.0, 1.0)
|
||||
_canvas.draw_rect(Rect2(pos, Vector2(w, BAR_H)), Color(0.12, 0.08, 0.1))
|
||||
_canvas.draw_rect(Rect2(pos, Vector2(w * frac, BAR_H)), Color(0.95, 0.35, 0.45))
|
||||
var rect := Rect2(pos, Vector2(w, BAR_H))
|
||||
_hslice(UiTheme.texture("bar"), rect, Color.WHITE)
|
||||
var inner := Rect2(rect.position + Vector2(3.0, 3.0),
|
||||
Vector2((w - 6.0) * frac, BAR_H - 6.0))
|
||||
if inner.size.x > 0.5:
|
||||
_canvas.draw_texture_rect(UiTheme.texture("bar_fill"), inner, false,
|
||||
Color(0.95, 0.35, 0.45))
|
||||
var phase_index := int(b["phase"])
|
||||
var phase_name := ""
|
||||
if phase_index < client.boss_def.phases.size():
|
||||
@@ -310,9 +327,45 @@ func _draw_boss_bar() -> void:
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(1.0, 0.75, 0.8))
|
||||
|
||||
|
||||
func _bar(pos: Vector2, frac: float, fill: Color, back: Color) -> void:
|
||||
_canvas.draw_rect(Rect2(pos, Vector2(BAR_W, BAR_H)), back)
|
||||
_canvas.draw_rect(Rect2(pos, Vector2(BAR_W * clampf(frac, 0.0, 1.0), BAR_H)), fill)
|
||||
## A bar in the pack's art: the frame stretched horizontally, the fill tinted.
|
||||
##
|
||||
## [param back] is no longer a colour -- the frame sprite is the background --
|
||||
## but the signature keeps it so callers read the same and a future flat-bar
|
||||
## fallback has somewhere to go.
|
||||
func _bar(pos: Vector2, frac: float, fill: Color, _back: Color) -> void:
|
||||
var rect := Rect2(pos, Vector2(BAR_W, BAR_H))
|
||||
_hslice(UiTheme.texture("bar"), rect, Color.WHITE)
|
||||
var inset := 3.0
|
||||
var inner := Rect2(rect.position + Vector2(inset, inset),
|
||||
Vector2((rect.size.x - inset * 2.0) * clampf(frac, 0.0, 1.0),
|
||||
rect.size.y - inset * 2.0))
|
||||
if inner.size.x > 0.5:
|
||||
# The fill sprite is a flat strip, so tinting it is the whole palette --
|
||||
# one texture covers health, experience, the escape channel and the
|
||||
# boss.
|
||||
_canvas.draw_texture_rect(UiTheme.texture("bar_fill"), inner, false, fill)
|
||||
|
||||
|
||||
## Draw a horizontally stretchable sprite as three slices: left cap, stretched
|
||||
## middle, right cap. Godot's nine-patch lives on nodes rather than on the
|
||||
## immediate-mode API, and the HUD is drawn rather than built out of controls.
|
||||
func _hslice(tex: Texture2D, rect: Rect2, tint: Color) -> void:
|
||||
if tex == null:
|
||||
return
|
||||
var src := Vector2(tex.get_size())
|
||||
var cap := minf(BAR_CAP, src.x * 0.5)
|
||||
var draw_cap := cap * (rect.size.y / src.y)
|
||||
_canvas.draw_texture_rect_region(tex,
|
||||
Rect2(rect.position, Vector2(draw_cap, rect.size.y)),
|
||||
Rect2(0.0, 0.0, cap, src.y), tint)
|
||||
_canvas.draw_texture_rect_region(tex,
|
||||
Rect2(rect.position + Vector2(draw_cap, 0.0),
|
||||
Vector2(maxf(rect.size.x - draw_cap * 2.0, 0.0), rect.size.y)),
|
||||
Rect2(cap, 0.0, src.x - cap * 2.0, src.y), tint)
|
||||
_canvas.draw_texture_rect_region(tex,
|
||||
Rect2(rect.position + Vector2(rect.size.x - draw_cap, 0.0),
|
||||
Vector2(draw_cap, rect.size.y)),
|
||||
Rect2(src.x - cap, 0.0, cap, src.y), tint)
|
||||
|
||||
|
||||
func flash_hit() -> void:
|
||||
|
||||
@@ -24,6 +24,7 @@ func _ready() -> void:
|
||||
# what actually caused the "menu stuck in the top-left corner" bug --
|
||||
# set_anchors_and_offsets_preset() sets both halves correctly in one call.
|
||||
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
UiTheme.apply_to(self)
|
||||
|
||||
# A CenterContainer recomputes its child's centered position itself, every
|
||||
# time the child's minimum size or the window size changes -- no manual
|
||||
|
||||
@@ -6,6 +6,7 @@ extends CanvasLayer
|
||||
## game's state that should change what it can do.
|
||||
|
||||
signal closed
|
||||
signal credits_requested
|
||||
|
||||
var _rows: Dictionary[String, Button] = {}
|
||||
var _status: Label
|
||||
@@ -20,8 +21,7 @@ var _listening: String = ""
|
||||
func _ready() -> void:
|
||||
layer = 32
|
||||
visible = false
|
||||
var root := Control.new()
|
||||
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
var root := UiTheme.themed_root()
|
||||
add_child(root)
|
||||
|
||||
var scrim := ColorRect.new()
|
||||
@@ -81,6 +81,11 @@ func _ready() -> void:
|
||||
_status.text = "controls reset"
|
||||
refresh())
|
||||
buttons.add_child(reset)
|
||||
var credits := Button.new()
|
||||
credits.text = "Credits"
|
||||
credits.custom_minimum_size = Vector2(160.0, 34.0)
|
||||
credits.pressed.connect(func() -> void: credits_requested.emit())
|
||||
buttons.add_child(credits)
|
||||
var close := Button.new()
|
||||
close.text = "Close"
|
||||
close.custom_minimum_size = Vector2(160.0, 34.0)
|
||||
|
||||
@@ -31,8 +31,7 @@ var _status: Label
|
||||
func _ready() -> void:
|
||||
layer = 28
|
||||
visible = false
|
||||
var root := Control.new()
|
||||
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
var root := UiTheme.themed_root()
|
||||
add_child(root)
|
||||
|
||||
var scrim := ColorRect.new()
|
||||
|
||||
@@ -10,6 +10,7 @@ extends Node2D
|
||||
@onready var characters: CanvasLayer = $CharacterSelect
|
||||
@onready var upgrades: CanvasLayer = $UpgradeScreen
|
||||
@onready var settings: CanvasLayer = $SettingsScreen
|
||||
@onready var credits: CanvasLayer = $CreditsScreen
|
||||
|
||||
var _bound: ClientRuntime = null
|
||||
## Opened deliberately from the menu, as opposed to forced open by having no
|
||||
@@ -37,6 +38,8 @@ func _ready() -> void:
|
||||
upgrades.closed.connect(func() -> void: _upgrades_open = false)
|
||||
menu.settings_requested.connect(func() -> void: settings.open())
|
||||
settings.closed.connect(func() -> void: settings.visible = false)
|
||||
settings.credits_requested.connect(func() -> void: credits.open())
|
||||
credits.closed.connect(func() -> void: credits.visible = false)
|
||||
upgrades.choose_requested.connect(func(i: int) -> void: Net.choose_upgrade(i))
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
class_name UiTheme
|
||||
extends RefCounted
|
||||
## The game's control theme, built in code from Crusenho's UI pack.
|
||||
##
|
||||
## Built rather than authored as a .tres for the same reason [Content] is code:
|
||||
## it is a readable diff, there are no resource UIDs churning in version
|
||||
## control, and a test can build it without the editor.
|
||||
##
|
||||
## Applied to each screen's root Control ([method apply_to]). It cannot be
|
||||
## applied once to the Window instead: Godot's theme lookup walks a Control's
|
||||
## ancestors, and the chain BREAKS at the first plain Node -- which for every
|
||||
## screen here is `main.gd` or a CanvasLayer. Setting `window.theme` looked like
|
||||
## it worked and changed nothing.
|
||||
|
||||
const DIR := "res://assets/sprites/ui/"
|
||||
|
||||
## Every texture the theme needs. Named so a test can assert they all load: a
|
||||
## missing file gives a null texture and a silently unstyled control, which
|
||||
## looks like the theme never being applied at all.
|
||||
const TEXTURES := {
|
||||
"button": DIR + "Button01a_1.png",
|
||||
"button_hover": DIR + "Button01a_2.png",
|
||||
"button_pressed": DIR + "Button01a_3.png",
|
||||
"button_disabled": DIR + "Button01a_4.png",
|
||||
"panel": DIR + "Frame01a.png",
|
||||
"panel_accent": DIR + "Frame02a.png",
|
||||
"slot": DIR + "FrameSlot01a.png",
|
||||
"slot_active": DIR + "FrameSlot01b.png",
|
||||
"bar": DIR + "Bar01a.png",
|
||||
"bar_fill": DIR + "BarFill01f.png",
|
||||
"field": DIR + "InputField01a.png",
|
||||
"banner": DIR + "Banner01a.png",
|
||||
}
|
||||
|
||||
## The art is cream with a black outline, so text on it has to be dark. Every
|
||||
## label the game draws over the dark world stays light; only controls that sit
|
||||
## on this pack's own surfaces flip.
|
||||
const INK := Color(0.12, 0.11, 0.13)
|
||||
const INK_DIM := Color(0.42, 0.40, 0.44)
|
||||
|
||||
static var _theme: Theme = null
|
||||
|
||||
|
||||
static func texture(key: String) -> Texture2D:
|
||||
return load(TEXTURES[key]) as Texture2D
|
||||
|
||||
|
||||
## One shared instance. Themes are pure data and every control only reads them,
|
||||
## so building a second would be waste rather than isolation.
|
||||
static func get_theme() -> Theme:
|
||||
if _theme != null:
|
||||
return _theme
|
||||
var t := Theme.new()
|
||||
t.default_font_size = 14
|
||||
|
||||
t.set_stylebox("normal", "Button", _box("button", 8, 12, 7))
|
||||
t.set_stylebox("hover", "Button", _box("button_hover", 8, 12, 7))
|
||||
t.set_stylebox("pressed", "Button", _box("button_pressed", 8, 12, 7))
|
||||
t.set_stylebox("disabled", "Button", _box("button_disabled", 8, 12, 7))
|
||||
# Focus deliberately reuses the hover art rather than adding an outline:
|
||||
# the pack has no focus state, and an invented one would be the only thing
|
||||
# on screen that is not from the set.
|
||||
t.set_stylebox("focus", "Button", _box("button_hover", 8, 12, 7))
|
||||
t.set_color("font_color", "Button", INK)
|
||||
t.set_color("font_hover_color", "Button", INK)
|
||||
t.set_color("font_pressed_color", "Button", INK)
|
||||
t.set_color("font_disabled_color", "Button", INK_DIM)
|
||||
t.set_color("font_focus_color", "Button", INK)
|
||||
|
||||
t.set_stylebox("panel", "Panel", _box("panel", 8, 10, 10))
|
||||
t.set_stylebox("panel", "PanelContainer", _box("panel", 8, 10, 10))
|
||||
|
||||
t.set_stylebox("normal", "LineEdit", _box("field", 8, 10, 6))
|
||||
t.set_stylebox("focus", "LineEdit", _box("field", 8, 10, 6))
|
||||
t.set_color("font_color", "LineEdit", INK)
|
||||
t.set_color("font_placeholder_color", "LineEdit", INK_DIM)
|
||||
t.set_color("caret_color", "LineEdit", INK)
|
||||
|
||||
_theme = t
|
||||
return _theme
|
||||
|
||||
|
||||
## Give [param root] the theme. Everything under it inherits, so one call per
|
||||
## screen covers that screen.
|
||||
##
|
||||
## Per-screen rather than once on the Window, because theme inheritance only
|
||||
## travels Control-to-Control: a Control whose parent is a plain Node or a
|
||||
## CanvasLayer resolves against the default theme however the Window is set.
|
||||
## Every screen in this game hangs off one or the other.
|
||||
static func apply_to(root: Control) -> void:
|
||||
if root != null:
|
||||
root.theme = get_theme()
|
||||
|
||||
|
||||
## A full-rect Control with the theme already on it -- the root every screen
|
||||
## built in code starts from. Using this rather than a bare Control is what
|
||||
## stops a new screen quietly rendering unstyled.
|
||||
static func themed_root() -> Control:
|
||||
var root := Control.new()
|
||||
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
apply_to(root)
|
||||
return root
|
||||
|
||||
|
||||
## A nine-patch stylebox. [param margin] is the corner size in source pixels;
|
||||
## the middle is what stretches.
|
||||
static func _box(key: String, margin: int, pad_x: int, pad_y: int) -> StyleBoxTexture:
|
||||
var sb := StyleBoxTexture.new()
|
||||
sb.texture = texture(key)
|
||||
for side in [SIDE_LEFT, SIDE_TOP, SIDE_RIGHT, SIDE_BOTTOM]:
|
||||
sb.set_texture_margin(side, float(margin))
|
||||
sb.set_content_margin(SIDE_LEFT, float(pad_x))
|
||||
sb.set_content_margin(SIDE_RIGHT, float(pad_x))
|
||||
sb.set_content_margin(SIDE_TOP, float(pad_y))
|
||||
sb.set_content_margin(SIDE_BOTTOM, float(pad_y))
|
||||
return sb
|
||||
@@ -0,0 +1 @@
|
||||
uid://ddaj0c8xfjitx
|
||||
Reference in New Issue
Block a user