- Added a new specification for the speech style toolbar live-edit, detailing immediate style application, scoped effect demonstration, and hover feedback. - Introduced refinements to the speech style toolbar, including improved chooser sizing, exclusive stage editing during speech composition, and removal of redundant controls. - Documented new stage controls for managing how the whole speech shows, including options for bubble arrangement, word appearance, and bubble motion. - Created a new log file for speech padding syntax to track engine versioning.
244 lines
14 KiB
GDScript
244 lines
14 KiB
GDScript
class_name SandboxTheme
|
|
extends RefCounted
|
|
## Validated, cached presentation data for the Sandbox Stage.
|
|
|
|
const SCHEMA_VERSION: String = "2.0"
|
|
const DEFAULT_PATH: String = "res://sandbox_theme.json"
|
|
|
|
const DEFAULT_CONFIG: Dictionary = {
|
|
"version": SCHEMA_VERSION,
|
|
"global": {"font_preset": "ui", "font_size": 18, "text_color": "#F5F7FA", "muted_text_color": "#AAB2C0", "bg_color": "#171B24F2", "surface_color": "#252B38FA", "border_color": "#FFFFFF33", "border_width": 1, "corner_radius": 10, "padding": 8, "spacing": 8},
|
|
"fonts": {"ui_font": "", "emoji_font": "", "ui_font_bold": "", "ui_font_italic": "", "presets": {"ui": {"label": "Standard", "path": ""}, "comic": {"label": "Comic", "path": "res://resources/fonts/comic.ttf", "speech_base_size": 28}, "pixel": {"label": "Pixel", "path": "res://resources/fonts/pixel.ttf", "speech_base_size": 40}, "scary": {"label": "Scary", "path": "res://resources/fonts/scary.ttf", "speech_base_size": 28}}, "sizes": {"action_popup": 24, "action_popup_emoji": 22, "assignment_badge": 20, "rule_label": 18, "status_pill": 18, "tooltip": 18, "file_menu": 22, "file_menu_popup": 26, "mode_switcher": 24, "timestamp_badge": 22, "queue_panel": 22, "rule_panel": 22, "action_editor": 22, "rule_editor": 22, "panel_row": 20, "panel_title": 24, "asset_label": 18, "asset_badge": 14}, "flags": {"panel_title_bold": true, "rule_label_bold": false, "badge_bold": true, "action_popup_bold": false, "action_popup_italic": false}},
|
|
"mode_colors": {"edit": "#22C6FF", "direct": "#FFB300", "play": "#33DD77", "guide": "#22C6FF"},
|
|
"grid": {"snap_size": 15.0},
|
|
"chrome": {"style_defaults": {}, "top_bar": {"height": 64}, "status_bar": {"height": 38}, "mode_switcher": {"segment_min_width": 124}, "mode_frame": {"border_width": 3}, "mode_badge": {"corner_radius": 12, "padding": 10}, "timestamp_badge": {"corner_radius": 10, "padding": 8, "bg_color": "#171B24F2"}, "tooltip": {"bg_color": "#101010E6", "border_width": 1}, "dialogs": {"queue_size":[680,520], "rule_size":[720,560], "action_size":[560,390], "confirm_size":[620,300], "font_size":22, "message_font_size":26}},
|
|
"popups": {"style_defaults": {"bg_color": "#202633FA", "border_color": "#FFFFFF33", "corner_radius": 12, "padding": 12}, "action_menu": {"font_size": 24}, "dialog": {}, "asset_selector": {"backdrop_alpha": 0.55, "cell_size":[190,220], "thumbnail_size":[156,156]}},
|
|
"palette": {"style_defaults": {}, "dock_width": 116, "card_size": [96, 94], "icon_size": 30, "drag_threshold": 8, "selected_border_width": 3},
|
|
"overlays": {"style_defaults": {"line_width": 2, "dash_length": 8, "dash_gap": 5}, "terrain_guide": {"color": "#22C6FF"}, "completed_path_alpha": 0.20, "completed_path_color": "#959EAD", "valid": {"color": "#4DE66680"}, "same_type": {"color": "#FFFFFF26"}, "invalid": {"color": "#FF4D4D80"}},
|
|
"widgets": {"swatches": ["#FFFFFF", "#111111", "#22C6FF", "#FFB300", "#33DD77", "#FF4D4D"], "size_presets": [{"id":"small", "label":"Small", "value":20}, {"id":"medium", "label":"Medium", "value":28}, {"id":"huge", "label":"HUGE", "value":40}], "emoji": ["😀", "😂", "😍", "😮", "😢", "😡", "👍", "❤️"]},
|
|
"speech": {"toolbar": {"surface":"#1C2431F7", "border":"#9EADBF", "selection_halo":"#FFD84D", "gap":10, "max_width":480, "min_hit_height":48}, "ui": {"panel_width": 440, "button_min_height": 44, "heading_size": 24}, "layout": {"max_width": 420, "stack_gap": 12, "screen_margin": 18, "bubble_padding": 18}, "animation": {"letter_interval": 0.045, "word_interval": 0.20, "pop_duration": 0.24}, "styles": {"talk": {"fill":"#FFFDF6", "outline":"#202020", "tail":"talk"}, "shout": {"fill":"#FFD642", "outline":"#D92F26", "tail":"shout"}, "think": {"fill":"#E9F5FF", "outline":"#37698B", "tail":"think"}, "whisper": {"fill":"#F2F2F2", "outline":"#6D7885", "tail":"whisper"}, "sfx": {"fill":"#FFDD39", "outline":"#D92F26", "tail":"none"}}, "text_colors": [{"name":"Ink","color":"#202020"},{"name":"Midnight","color":"#111827"},{"name":"Slate","color":"#475569"},{"name":"Snow","color":"#FFFFFF"},{"name":"Cloud","color":"#CBD5E1"},{"name":"Sky","color":"#2196F3"},{"name":"Ocean","color":"#0077B6"},{"name":"Teal","color":"#00897B"},{"name":"Leaf","color":"#2E7D32"},{"name":"Lime","color":"#8BC34A"},{"name":"Sunshine","color":"#FFC107"},{"name":"Orange","color":"#F57C00"},{"name":"Sunset red","color":"#E53935"},{"name":"Rose","color":"#D81B60"},{"name":"Violet","color":"#7E57C2"},{"name":"Brown","color":"#795548"}], "preview_ghost":"#FFFFFF99", "connector":"#FFFFFFB0"}
|
|
}
|
|
|
|
var _config: Dictionary = DEFAULT_CONFIG.duplicate(true)
|
|
var _font_cache: Dictionary = {}
|
|
var _style_cache: Dictionary = {}
|
|
var _control_theme: Theme = null
|
|
var _warned: Dictionary = {}
|
|
|
|
func load_from(path: String = DEFAULT_PATH) -> bool:
|
|
invalidate_caches()
|
|
_config = DEFAULT_CONFIG.duplicate(true)
|
|
if not FileAccess.file_exists(path):
|
|
_warn_once(path, "SandboxTheme: '%s' was not found; using defaults." % path)
|
|
return false
|
|
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
|
|
if file == null:
|
|
_warn_once(path, "SandboxTheme: '%s' could not be read; using defaults." % path)
|
|
return false
|
|
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
|
file.close()
|
|
if not parsed is Dictionary:
|
|
_warn_once(path, "SandboxTheme: '%s' is malformed; using defaults." % path)
|
|
return false
|
|
_apply_legacy_aliases(parsed as Dictionary)
|
|
_config = _deep_merge(_config, parsed as Dictionary)
|
|
return true
|
|
|
|
func raw_config() -> Dictionary:
|
|
return _config.duplicate(true)
|
|
|
|
func value(path: String, fallback: Variant = null) -> Variant:
|
|
var cursor: Variant = _config
|
|
for piece: String in path.split("."):
|
|
if not cursor is Dictionary or not (cursor as Dictionary).has(piece):
|
|
return fallback
|
|
cursor = (cursor as Dictionary)[piece]
|
|
return cursor
|
|
|
|
func color(path: String, fallback: Color) -> Color:
|
|
var raw: Variant = value(path, null)
|
|
if not raw is String:
|
|
return fallback
|
|
var text: String = String(raw)
|
|
if not text.begins_with("#") or (text.length() != 7 and text.length() != 9):
|
|
_warn_once(path, "SandboxTheme: invalid color at '%s'." % path)
|
|
return fallback
|
|
return Color.from_string(text, fallback)
|
|
|
|
func number(path: String, fallback: float, minimum: float = -INF, maximum: float = INF) -> float:
|
|
var raw: Variant = value(path, fallback)
|
|
if not raw is int and not raw is float:
|
|
_warn_once(path, "SandboxTheme: invalid number at '%s'." % path)
|
|
return fallback
|
|
var n: float = float(raw)
|
|
if not is_finite(n): return fallback
|
|
return clampf(n, minimum, maximum)
|
|
|
|
func integer(path: String, fallback: int, minimum: int = -2147483648, maximum: int = 2147483647) -> int:
|
|
return clampi(int(roundi(number(path, float(fallback), float(minimum), float(maximum)))), minimum, maximum)
|
|
|
|
func font(component_path: String = "", preset_id: String = "ui") -> Font:
|
|
var path: String = ""
|
|
if not component_path.is_empty(): path = String(value(component_path + ".font_path", ""))
|
|
if path.is_empty(): path = String(value("fonts.presets.%s.path" % preset_id, ""))
|
|
if path.is_empty(): path = String(value("fonts.ui_font", ""))
|
|
if path.is_empty():
|
|
if preset_id == "ui": return ThemeDB.fallback_font
|
|
var system_key: String = "system:" + preset_id
|
|
if _font_cache.has(system_key): return _font_cache[system_key]
|
|
var system_font := SystemFont.new()
|
|
match preset_id:
|
|
"comic": system_font.font_names = PackedStringArray(["Comic Sans MS", "Comic Neue", "Chalkboard", "Comic Sans", "sans-serif"])
|
|
"pixel": system_font.font_names = PackedStringArray(["Consolas", "Courier New", "Liberation Mono", "monospace"])
|
|
"scary": system_font.font_names = PackedStringArray(["Impact", "Chiller", "Papyrus", "fantasy", "sans-serif"])
|
|
_: system_font.font_names = PackedStringArray(["Arial", "Liberation Sans", "sans-serif"])
|
|
_font_cache[system_key] = system_font
|
|
return system_font
|
|
if _font_cache.has(path): return _font_cache[path]
|
|
var resource: Resource = ResourceLoader.load(path) if ResourceLoader.exists(path) else null
|
|
if resource is Font:
|
|
_font_cache[path] = resource
|
|
return resource as Font
|
|
_warn_once("font." + path, "SandboxTheme: font '%s' is unavailable; using fallback." % path)
|
|
return ThemeDB.fallback_font
|
|
|
|
func speech_font(preset_id: String = "ui") -> Font:
|
|
return font("", preset_id)
|
|
|
|
|
|
func speech_font_size(preset_id: String, size_preset: String) -> int:
|
|
var base_size: int = integer("fonts.presets.%s.speech_base_size" % preset_id, 28, 8, 96)
|
|
var scale: float = 1.0
|
|
match size_preset:
|
|
"small": scale = 0.72
|
|
"huge": scale = 1.43
|
|
return clampi(roundi(float(base_size) * scale), 8, 128)
|
|
|
|
func emoji_font() -> Font:
|
|
var path: String = String(value("fonts.emoji_font", ""))
|
|
if path.is_empty(): return font()
|
|
return font("", "emoji") if ResourceLoader.exists(path) else font()
|
|
|
|
func style(component_path: String) -> Dictionary:
|
|
var result: Dictionary = {}
|
|
var section: String = component_path.get_slice(".", 0)
|
|
result = _deep_merge(result, value("global", {}))
|
|
result = _deep_merge(result, value(section + ".style_defaults", {}))
|
|
result = _deep_merge(result, value(component_path, {}))
|
|
return result
|
|
|
|
func stylebox(component_path: String) -> StyleBoxFlat:
|
|
var cfg: Dictionary = style(component_path)
|
|
var key: String = JSON.stringify(cfg)
|
|
if not _style_cache.has(key):
|
|
var box := StyleBoxFlat.new()
|
|
box.bg_color = _style_color(cfg, "bg_color", color("global.surface_color", Color("#252B38")))
|
|
box.border_color = _style_color(cfg, "border_color", color("global.border_color", Color("#FFFFFF33")))
|
|
box.set_border_width_all(int(cfg.get("border_width", integer("global.border_width", 1, 0, 16))))
|
|
box.set_corner_radius_all(int(cfg.get("corner_radius", integer("global.corner_radius", 10, 0, 64))))
|
|
var padding: int = int(cfg.get("padding", integer("global.padding", 8, 0, 64)))
|
|
box.content_margin_left = padding; box.content_margin_right = padding
|
|
box.content_margin_top = padding; box.content_margin_bottom = padding
|
|
_style_cache[key] = box
|
|
return (_style_cache[key] as StyleBoxFlat).duplicate()
|
|
|
|
func control_theme() -> Theme:
|
|
if _control_theme == null:
|
|
_control_theme = Theme.new()
|
|
_control_theme.default_font = font()
|
|
_control_theme.default_font_size = integer("global.font_size", 18, 8, 96)
|
|
return _control_theme
|
|
|
|
func swatches() -> Array[Color]:
|
|
var result: Array[Color] = []
|
|
for item: Variant in value("widgets.swatches", []):
|
|
if item is String: result.append(Color.from_string(String(item), Color.WHITE))
|
|
return result
|
|
|
|
func size_presets() -> Array[Dictionary]:
|
|
var result: Array[Dictionary] = []
|
|
for item: Variant in value("widgets.size_presets", []):
|
|
if item is Dictionary and not String((item as Dictionary).get("id", "")).is_empty(): result.append((item as Dictionary).duplicate(true))
|
|
if result.is_empty(): result.append({"id":"medium", "label":"Medium", "value":28})
|
|
return result
|
|
|
|
func font_presets() -> Array[Dictionary]:
|
|
var result: Array[Dictionary] = []
|
|
var presets: Dictionary = value("fonts.presets", {})
|
|
for id: String in presets:
|
|
var entry: Dictionary = presets[id] if presets[id] is Dictionary else {}
|
|
result.append({"id": id, "label": String(entry.get("label", id.capitalize())), "font": font("", id)})
|
|
if result.is_empty(): result.append({"id":"ui", "label":"Standard", "font":font()})
|
|
return result
|
|
|
|
func emoji_items() -> Array[String]:
|
|
var result: Array[String] = []
|
|
for item: Variant in value("widgets.emoji", []): if item is String: result.append(String(item))
|
|
return result
|
|
|
|
func speech_color(frame: String, channel: String, fallback: Color) -> Color:
|
|
if not ["talk", "shout", "think", "whisper", "sfx"].has(frame) or not ["fill", "outline"].has(channel):
|
|
return fallback
|
|
return color("speech.styles.%s.%s" % [frame, channel], fallback)
|
|
|
|
func speech_number(token: String, fallback: float, minimum: float, maximum: float) -> float:
|
|
if not ["ui.panel_width", "ui.button_min_height", "ui.heading_size", "layout.max_width", "layout.stack_gap", "layout.screen_margin", "layout.bubble_padding", "animation.letter_interval", "animation.word_interval", "animation.pop_duration"].has(token):
|
|
return fallback
|
|
return number("speech." + token, fallback, minimum, maximum)
|
|
|
|
|
|
func speech_toolbar_number(token: String, fallback: float, minimum: float, maximum: float) -> float:
|
|
if not ["gap", "max_width", "min_hit_height"].has(token):
|
|
return fallback
|
|
return number("speech.toolbar." + token, fallback, minimum, maximum)
|
|
|
|
func speech_text_colors() -> Array[Dictionary]:
|
|
var result: Array[Dictionary] = []
|
|
var raw_colors: Variant = value("speech.text_colors", [])
|
|
if not raw_colors is Array:
|
|
return result
|
|
for raw: Variant in raw_colors as Array:
|
|
if not raw is Dictionary:
|
|
continue
|
|
var item: Dictionary = raw as Dictionary
|
|
var name: String = String(item.get("name", "")).strip_edges()
|
|
var color_text: String = String(item.get("color", ""))
|
|
if not name.is_empty() and _is_valid_hex_color(color_text):
|
|
result.append({"name": name, "color": Color.from_string(color_text, Color.BLACK)})
|
|
return result
|
|
|
|
func _is_valid_hex_color(text: String) -> bool:
|
|
if text.length() != 7 and text.length() != 9 or not text.begins_with("#"):
|
|
return false
|
|
for index: int in range(1, text.length()):
|
|
if not "0123456789abcdefABCDEF".contains(text.substr(index, 1)):
|
|
return false
|
|
return true
|
|
|
|
func invalidate_caches() -> void:
|
|
_font_cache.clear(); _style_cache.clear(); _control_theme = null; _warned.clear()
|
|
|
|
func _apply_legacy_aliases(source: Dictionary) -> void:
|
|
var modes: Dictionary = source.get("mode_colors", {})
|
|
for pair: Array in [["edit_accent","edit"], ["direct_accent","direct"], ["play_accent","play"], ["guide_line","guide"]]:
|
|
if modes.has(pair[0]) and not modes.has(pair[1]): modes[pair[1]] = modes[pair[0]]
|
|
var fonts: Dictionary = source.get("fonts", {})
|
|
var sizes: Dictionary = fonts.get("sizes", {})
|
|
for name: String in ["action_popup", "action_popup_emoji", "assignment_badge", "rule_label", "status_pill", "tooltip", "queue_panel", "rule_panel", "action_editor", "rule_editor", "panel_row", "panel_title"]:
|
|
var legacy: String = name + "_font_size"
|
|
if fonts.has(legacy) and not sizes.has(name): sizes[name] = fonts[legacy]
|
|
fonts["sizes"] = sizes; source["fonts"] = fonts; source["mode_colors"] = modes
|
|
|
|
func _deep_merge(base: Dictionary, overrides: Dictionary) -> Dictionary:
|
|
var merged: Dictionary = base.duplicate(true)
|
|
for key: Variant in overrides:
|
|
if merged.get(key) is Dictionary and overrides[key] is Dictionary: merged[key] = _deep_merge(merged[key], overrides[key])
|
|
else: merged[key] = overrides[key]
|
|
return merged
|
|
|
|
func _style_color(cfg: Dictionary, key: String, fallback: Color) -> Color:
|
|
return Color.from_string(String(cfg.get(key, "")), fallback)
|
|
|
|
func _warn_once(key: String, message: String) -> void:
|
|
if not _warned.has(key): _warned[key] = true; push_warning(message)
|