feat(win): T-WIN-001 Godot 工程骨架 + 4 个 autoload

This commit is contained in:
2026-09-14 20:04:54 +08:00
parent 0b7083d026
commit 85a2568266
13 changed files with 868 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<rect width="128" height="128" rx="24" fill="#1b1f2a"/>
<g stroke="#4ea1ff" stroke-width="6" stroke-linecap="round" fill="none">
<path d="M64 24v14"/>
<path d="M64 38 L44 60 M64 38 L84 60"/>
<path d="M44 60 L36 86 M84 60 L92 86"/>
<path d="M64 38 L64 78"/>
<path d="M64 78 L52 104 M64 78 L76 104"/>
</g>
<g fill="#ffd166">
<circle cx="64" cy="24" r="7"/>
<circle cx="44" cy="60" r="5"/>
<circle cx="84" cy="60" r="5"/>
<circle cx="36" cy="86" r="5"/>
<circle cx="92" cy="86" r="5"/>
<circle cx="64" cy="78" r="5"/>
<circle cx="52" cy="104" r="5"/>
<circle cx="76" cy="104" r="5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 743 B

+55
View File
@@ -0,0 +1,55 @@
; OpenDance Studio —— Windows 客户端(Godot 4)
;
; 契约版本:见 contracts/VERSION(与 opendance-web 冻结的 v0.1.0 对齐)
; 渲染方式选 gl_compatibility:兼容性最好,便于在 Linux CI 上无 GPU 导出。
config_version=5
[application]
config/name="OpenDance Studio"
config/description="摄像头跳舞评分系统 —— Windows 客户端。接收 Android 辅助 App 的 33 点骨骼关键点,驱动 VRM 角色并 DTW 实时评分。"
config/version="0.1.0"
run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.7", "GL Compatibility")
config/icon="res://icon.svg"
boot_splash/show_image=false
[audio]
buses/default_bus_layout=""
[autoload]
AppConfig="*res://src/autoload/app_config.gd"
CatalogStore="*res://src/autoload/catalog_store.gd"
PoseReceiver="*res://src/autoload/pose_receiver.gd"
ScoreService="*res://src/autoload/score_service.gd"
[display]
window/size/viewport_width=1280
window/size/viewport_height=720
window/size/window_width_override=1280
window/size/window_height_override=720
window/stretch/mode="canvas_items"
window/stretch/aspect="expand"
[file_customization]
folder_colors={
"res://src/": "blue",
"res://scenes/": "green",
"res://tests/": "purple"
}
[input_devices]
pointing/emulate_touch_from_mouse=false
[rendering]
renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="gl_compatibility"
textures/canvas_textures/default_texture_filter=1
anti_aliasing/quality/msaa_3d=1
+12
View File
@@ -0,0 +1,12 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://src/ui/main.gd" id="1_main"]
[node name="Main" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_main")
+114
View File
@@ -0,0 +1,114 @@
extends Node
## T-WIN-001/T-WIN-002:客户端配置
##
## 持久化到 user://opendance.cfg(ConfigFile),不入仓库。
## 所有可调参数集中在此,便于打包后由用户修改而无需重新编译。
signal loaded()
signal changed()
const CONFIG_PATH := "user://opendance.cfg"
const SECTION := "opendance"
## Web 服务端基地址(契约 servers[0])
var api_base_url: String = "http://127.0.0.1:3000"
## 本端 WebSocket 监听地址,Android App 主动连入(契约 §1 ws://<windows-host>:8765/pose)
var listen_host: String = "0.0.0.0"
var listen_port: int = 8765
## WebSocket 子协议(契约 §1)
var ws_subprotocol: String = "opendance.pose.v1"
## DTW 带宽(帧)
var dtw_band: int = 15
## 评分容差:平均每步代价达到该值时得分归零
var score_tolerance: float = 0.25
## 显示镜像预览(Android 前置摄像头镜像时用于观感一致)
var mirror_preview: bool = true
## 全屏启动
var fullscreen: bool = false
var is_loaded: bool = false
func _ready() -> void:
if not is_loaded:
load_from_disk()
func load_from_disk() -> void:
var config := ConfigFile.new()
var err := config.load(CONFIG_PATH)
if err == OK:
api_base_url = str(config.get_value(SECTION, "api_base_url", api_base_url))
listen_host = str(config.get_value(SECTION, "listen_host", listen_host))
listen_port = int(config.get_value(SECTION, "listen_port", listen_port))
ws_subprotocol = str(config.get_value(SECTION, "ws_subprotocol", ws_subprotocol))
dtw_band = int(config.get_value(SECTION, "dtw_band", dtw_band))
score_tolerance = float(config.get_value(SECTION, "score_tolerance", score_tolerance))
mirror_preview = bool(config.get_value(SECTION, "mirror_preview", mirror_preview))
fullscreen = bool(config.get_value(SECTION, "fullscreen", fullscreen))
else:
# 首次运行:写入默认值,便于用户直接编辑
save_to_disk()
_normalize()
is_loaded = true
loaded.emit()
changed.emit()
func save_to_disk() -> void:
_normalize()
var config := ConfigFile.new()
config.set_value(SECTION, "api_base_url", api_base_url)
config.set_value(SECTION, "listen_host", listen_host)
config.set_value(SECTION, "listen_port", listen_port)
config.set_value(SECTION, "ws_subprotocol", ws_subprotocol)
config.set_value(SECTION, "dtw_band", dtw_band)
config.set_value(SECTION, "score_tolerance", score_tolerance)
config.set_value(SECTION, "mirror_preview", mirror_preview)
config.set_value(SECTION, "fullscreen", fullscreen)
config.save(CONFIG_PATH)
## 用字典批量更新(设置界面用),随后自动持久化
func apply_settings(values: Dictionary) -> void:
for key in values:
if key in self:
set(key, values[key])
save_to_disk()
changed.emit()
func reset_to_defaults() -> void:
api_base_url = "http://127.0.0.1:3000"
listen_host = "0.0.0.0"
listen_port = 8765
ws_subprotocol = "opendance.pose.v1"
dtw_band = 15
score_tolerance = 0.25
mirror_preview = true
fullscreen = false
save_to_disk()
changed.emit()
func _normalize() -> void:
api_base_url = api_base_url.strip_edges().rstrip("/")
if api_base_url == "":
api_base_url = "http://127.0.0.1:3000"
if not api_base_url.begins_with("http://") and not api_base_url.begins_with("https://"):
api_base_url = "http://" + api_base_url
listen_port = clampi(listen_port, 1, 65535)
dtw_band = clampi(dtw_band, 1, 120)
score_tolerance = clampf(score_tolerance, 0.01, 5.0)
## 供 UI 显示:本端 WebSocket 对外地址
func listen_url() -> String:
return "ws://%s:%d/pose" % [listen_host, listen_port]
func config_path() -> String:
return ProjectSettings.globalize_path(CONFIG_PATH)
+1
View File
@@ -0,0 +1 @@
uid://csmakru5y6laa
+169
View File
@@ -0,0 +1,169 @@
extends Node
## T-WIN-002:曲目 / 动作 / 模型目录缓存
##
## 数据来源:契约 GET /api/catalog/export(只含 status = published 的资源)。
## 参考关键点序列:GET /api/motions/{id}/keypoints(NDJSON)。
signal catalog_loaded()
signal catalog_failed(message: String)
signal sequence_loaded(motion_id: int, frame_count: int)
signal sequence_failed(motion_id: int, message: String)
const CatalogTypes = preload("res://src/net/catalog_types.gd")
const ApiClient = preload("res://src/net/api_client.gd")
const KeypointSequence = preload("res://src/net/keypoint_sequence.gd")
const VrmLoader = preload("res://src/model/vrm_loader.gd")
var bundle: CatalogTypes.CatalogBundle = null
var contract_version: Dictionary = {}
var revision: String = ""
var is_loading: bool = false
var last_error: String = ""
## 参考序列缓存:motion_id → KeypointSequence.ParsedSequence
var _sequence_cache: Dictionary = {}
var _client: ApiClient = null
func _ready() -> void:
_client = ApiClient.new(AppConfig.api_base_url)
add_child(_client)
AppConfig.changed.connect(_on_config_changed)
func _on_config_changed() -> void:
if _client != null:
_client.set_base_url(AppConfig.api_base_url)
func client() -> ApiClient:
return _client
## 服务端可达性检查(契约 GET /health)
func check_health() -> Dictionary:
return await _client.health()
func is_loaded() -> bool:
return bundle != null
func songs() -> Array:
return bundle.songs if bundle != null else []
func motions() -> Array:
return bundle.motions if bundle != null else []
func models() -> Array:
return bundle.models if bundle != null else []
func default_model() -> CatalogTypes.Model:
return bundle.default_model() if bundle != null else null
## 拉取目录。成功后 bundle 被替换并发出 catalog_loaded。
func refresh() -> Dictionary:
if is_loading:
return {"ok": false, "error": "正在加载中"}
is_loading = true
last_error = ""
var version_result: Dictionary = await _client.catalog_version()
if version_result["ok"]:
contract_version = version_result["data"].get("contractVersion", {})
revision = str(version_result["data"].get("revision", ""))
var result: Dictionary = await _client.catalog_export()
is_loading = false
if not result["ok"]:
last_error = str(result["error"])
catalog_failed.emit(last_error)
return result
var parsed: CatalogTypes.CatalogBundle = CatalogTypes.CatalogBundle.from_dict(result["data"])
bundle = parsed
contract_version = parsed.contract_version
_sequence_cache.clear()
catalog_loaded.emit()
return {"ok": true, "data": parsed}
## 拉取某动作的关键点序列(带缓存)
## 返回 { ok, sequence, error }
func fetch_sequence(motion_id: int) -> Dictionary:
if _sequence_cache.has(motion_id):
return {"ok": true, "sequence": _sequence_cache[motion_id], "cached": true}
var motion := bundle.find_motion(motion_id) if bundle != null else null
if motion != null and not motion.is_scorable():
var reason := "动作 #%d 尚未完成关键点预处理(当前状态:%s)" % [motion_id, motion.preprocess_label()]
sequence_failed.emit(motion_id, reason)
return {"ok": false, "error": reason}
var result: Dictionary = await _client.motion_keypoints(motion_id)
if not result["ok"]:
var message := str(result["error"])
sequence_failed.emit(motion_id, message)
return {"ok": false, "error": message}
var sequence: KeypointSequence.ParsedSequence = KeypointSequence.parse_ndjson(str(result["data"]))
if sequence.frame_count() == 0:
var message := "关键点序列为空或格式不符(跳过 %d 行)" % sequence.skipped_lines
sequence_failed.emit(motion_id, message)
return {"ok": false, "error": message}
_sequence_cache[motion_id] = sequence
sequence_loaded.emit(motion_id, sequence.frame_count())
return {"ok": true, "sequence": sequence}
func cached_sequence(motion_id: int) -> KeypointSequence.ParsedSequence:
return _sequence_cache.get(motion_id, null)
func clear_sequence_cache() -> void:
_sequence_cache.clear()
## 下载模型文件到本地缓存;已存在则直接复用
## 返回 { ok, path, error, cached }
func ensure_model_cached(model: CatalogTypes.Model) -> Dictionary:
if model == null or model.file_url == "":
return {"ok": false, "error": "模型缺少可下载的文件地址"}
var path := VrmLoader.cache_path_for(model.id, model.file_url)
if FileAccess.file_exists(path):
return {"ok": true, "path": path, "cached": true}
var result: Dictionary = await _client.get_binary(model.file_url)
if not result["ok"]:
return {"ok": false, "error": str(result["error"])}
var bytes: PackedByteArray = result["data"]
if bytes.is_empty():
return {"ok": false, "error": "模型文件为空"}
DirAccess.make_dir_recursive_absolute(path.get_base_dir())
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
return {"ok": false, "error": "无法写入缓存 %s(%s)" % [path, error_string(FileAccess.get_open_error())]}
file.store_buffer(bytes)
file.close()
return {"ok": true, "path": path, "cached": false, "bytes": bytes.size()}
## 单曲目下的可评分动作
func scorable_motions_for_song(song_id: int) -> Array:
var out: Array = []
if bundle == null:
return out
for motion in bundle.motions_for_song(song_id):
if motion.is_scorable():
out.append(motion)
return out
+1
View File
@@ -0,0 +1 @@
uid://dk0t5uraxyawn
+110
View File
@@ -0,0 +1,110 @@
extends Node
## T-WIN-003:关键点接收服务(autoload 封装)
##
## 把 PoseServer 绑定到 AppConfig 的监听配置,并把最新一帧暴露给渲染/评分链路。
signal started(port: int)
signal start_failed(reason: String)
signal client_connected(device: String)
signal client_disconnected(reason: String)
signal frame_received(frame: KeypointFrame.PoseFrame)
signal stats_updated(stats: Dictionary)
const PoseServer = preload("res://src/net/pose_server.gd")
const KeypointFrame = preload("res://src/net/keypoint_frame.gd")
var server: PoseServer = null
## 最近一帧(驱动 VRM 与评分);无数据时为 null
var latest_frame: KeypointFrame.PoseFrame = null
var last_frame_at_ms: int = 0
var last_stats: Dictionary = {}
## 是否处于「接收中」
var active: bool = false
func _ready() -> void:
server = PoseServer.new()
server.name = "PoseServer"
add_child(server)
server.listening.connect(_on_listening)
server.listen_failed.connect(_on_listen_failed)
server.client_connected.connect(_on_client_connected)
server.client_disconnected.connect(_on_client_disconnected)
server.frame_received.connect(_on_frame_received)
server.stats_updated.connect(_on_stats_updated)
func start() -> Error:
if server == null:
return FAILED
server.expected_fps = 30
var err := server.listen(AppConfig.listen_host, AppConfig.listen_port)
if err == OK:
active = true
return err
func stop() -> void:
if server != null:
server.stop()
active = false
latest_frame = null
func is_listening() -> bool:
return server != null and server.is_running()
func connected_client_count() -> int:
return server.connected_count() if server != null else 0
## 设置要下发给 Android 端的参数(契约 §3.3 config)
func apply_remote_config(config: Dictionary) -> void:
if server == null:
return
if config.has("mirror"):
server.mirror = bool(config["mirror"])
if config.has("targetFps"):
server.expected_fps = int(config["targetFps"])
if config.has("upperBodyOnly"):
server.upper_body_only = bool(config["upperBodyOnly"])
if config.has("resolution"):
server.resolution = str(config["resolution"])
server.broadcast_config()
func snapshot() -> Dictionary:
return server.snapshot() if server != null else {}
func _on_listening(port: int) -> void:
started.emit(port)
func _on_listen_failed(port: int, reason: String) -> void:
active = false
start_failed.emit("监听 %d 端口失败:%s" % [port, reason])
func _on_client_connected(_client_id: int, device: String) -> void:
client_connected.emit(device)
func _on_client_disconnected(_client_id: int, reason: String) -> void:
latest_frame = null
client_disconnected.emit(reason)
func _on_frame_received(frame: KeypointFrame.PoseFrame) -> void:
latest_frame = frame
last_frame_at_ms = Time.get_ticks_msec()
frame_received.emit(frame)
func _on_stats_updated(stats: Dictionary) -> void:
last_stats = stats
stats_updated.emit(stats)
+1
View File
@@ -0,0 +1 @@
uid://dkxdsbrbi7hsx
+124
View File
@@ -0,0 +1,124 @@
extends Node
## T-WIN-007/T-WIN-008:评分服务
##
## 把「实时关键点流」与「服务端参考序列」接进 DTW 引擎,产出可展示的分数。
## 纯逻辑部分集中在 Scoring 类中(src/score/scoring.gd),便于无头测试。
signal session_started(motion_id: int, reference_frames: int)
signal score_updated(result: Dictionary)
signal session_finished(summary: Dictionary)
const DtwMatcher = preload("res://src/score/dtw.gd")
const PoseFeatures = preload("res://src/score/pose_features.gd")
const Scoring = preload("res://src/score/scoring.gd")
var active: bool = false
var motion_id: int = -1
var reference: Array = []
var _dtw: DtwMatcher.StreamingDtw = null
var _tolerance: float = 0.25
var _smoothing: float = 0.25
var _smoothed_score: float = 0.0
var _peak_score: float = 0.0
var _combo: int = 0
var _max_combo: int = 0
var _frames_scored: int = 0
var _score_sum: float = 0.0
var _last_result: Dictionary = {}
func _ready() -> void:
PoseReceiver.frame_received.connect(_on_frame_received)
## 用参考序列开启一次评分
func start_session(reference_features: Array, target_motion_id: int = -1, tolerance: float = -1.0) -> void:
stop_session()
reference = reference_features
motion_id = target_motion_id
_tolerance = tolerance if tolerance > 0.0 else AppConfig.score_tolerance
_dtw = DtwMatcher.StreamingDtw.new(reference, AppConfig.dtw_band)
_smoothed_score = 0.0
_peak_score = 0.0
_combo = 0
_max_combo = 0
_frames_scored = 0
_score_sum = 0.0
_last_result = {}
active = true
session_started.emit(motion_id, reference.size())
func stop_session() -> void:
active = false
_dtw = null
reference = []
func is_active() -> bool:
return active
## 推进一帧姿态,返回本次评分结果
func push_frame(frame: Object) -> Dictionary:
if not active or _dtw == null or frame == null:
return {}
var feature := PoseFeatures.build(frame)
var raw := _dtw.push(feature)
var normalized: float = raw["normalized"]
var instant := Scoring.score_from_cost(normalized, _tolerance)
_frames_scored += 1
_score_sum += instant
_smoothed_score = Scoring.smooth(_smoothed_score, instant, _smoothing)
_peak_score = maxf(_peak_score, instant)
if instant >= Scoring.COMBO_THRESHOLD:
_combo += 1
_max_combo = maxi(_max_combo, _combo)
else:
_combo = 0
_last_result = {
"score": _smoothed_score,
"instant": instant,
"peak": _peak_score,
"cost": normalized,
"combo": _combo,
"max_combo": _max_combo,
"progress": _dtw.progress(),
"reference_cursor": _dtw.reference_cursor(),
"frames": _frames_scored,
"grade": Scoring.grade(_smoothed_score),
}
score_updated.emit(_last_result)
return _last_result
func last_result() -> Dictionary:
return _last_result
## 结束并给出总结
func finish() -> Dictionary:
var average := _score_sum / float(maxi(_frames_scored, 1))
var summary := {
"motion_id": motion_id,
"frames_scored": _frames_scored,
"average_score": average,
"peak_score": _peak_score,
"final_score": _smoothed_score,
"max_combo": _max_combo,
"grade": Scoring.grade(_smoothed_score),
"reference_frames": reference.size(),
"completion": _dtw.progress() if _dtw != null else 0.0,
}
active = false
session_finished.emit(summary)
return summary
func _on_frame_received(frame: Object) -> void:
if active:
push_frame(frame)
+1
View File
@@ -0,0 +1 @@
uid://cyyv3nvnqlqma
+116
View File
@@ -0,0 +1,116 @@
extends Control
## 应用外壳:构建界面骨架、管理页面切换、启动接收服务。
##
## 界面全部由代码构建(见 src/ui/ui_kit.gd 的说明),
## scenes/main.tscn 只挂载本脚本。
const UiKit = preload("res://src/ui/ui_kit.gd")
const MainMenuScreen = preload("res://src/ui/main_menu.gd")
const GameScreen = preload("res://src/ui/game_screen.gd")
const ResultScreen = preload("res://src/ui/result_screen.gd")
const AboutScreen = preload("res://src/ui/about_screen.gd")
var _screen_host: Control
var _status_label: Label
var _menu: MainMenuScreen
var _game: GameScreen
var _result: ResultScreen
var _about: AboutScreen
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
UiKit.apply_root_theme(self)
var bg := ColorRect.new()
bg.color = UiKit.COLOR_BG
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(bg)
var layout := UiKit.vbox(0)
layout.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(layout)
_screen_host = Control.new()
_screen_host.size_flags_vertical = Control.SIZE_EXPAND_FILL
_screen_host.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_screen_host.clip_contents = true
layout.add_child(_screen_host)
var status_bar := UiKit.panel(UiKit.COLOR_PANEL, 0)
_status_label = UiKit.label("就绪", UiKit.FONT_SMALL, UiKit.COLOR_MUTED)
status_bar.add_child(_status_label)
layout.add_child(status_bar)
_build_screens()
if AppConfig.fullscreen:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
# 启动 WebSocket 接收服务(Android 辅助 App 连入本端)
var err := PoseReceiver.start()
if err == OK:
set_status("接收服务已启动:%s | 服务端 %s" % [AppConfig.listen_url(), AppConfig.api_base_url])
else:
set_status("接收服务启动失败(端口 %d 可能被占用);服务端 %s" % [AppConfig.listen_port, AppConfig.api_base_url])
PoseReceiver.client_connected.connect(func(device): set_status("Android 设备已连接:%s" % (device if device != "" else "未命名设备")))
PoseReceiver.client_disconnected.connect(func(reason): set_status("Android 设备断开:%s" % reason))
AppConfig.changed.connect(func(): pass)
goto_main_menu()
func _build_screens() -> void:
_menu = MainMenuScreen.new(_on_start_session, goto_about)
_screen_host.add_child(_menu)
_game = GameScreen.new(goto_main_menu, _on_session_finished)
_game.visible = false
_screen_host.add_child(_game)
_result = ResultScreen.new(_on_retry, goto_main_menu, goto_about)
_result.visible = false
_screen_host.add_child(_result)
_about = AboutScreen.new(goto_main_menu)
_about.visible = false
_screen_host.add_child(_about)
func _show_only(target: Control) -> void:
for screen in [_menu, _game, _result, _about]:
if screen != null:
screen.visible = screen == target
func goto_main_menu() -> void:
_show_only(_menu)
func goto_about() -> void:
_show_only(_about)
func _on_start_session(song: Object, motion: Object, model: Object) -> void:
_show_only(_game)
_game.prepare(song, motion, model)
set_status("准备跟练:%s" % (motion.name if motion != null else "未选择动作"))
func _on_session_finished(summary: Dictionary, source: Object, asset_name: String) -> void:
_result.show_summary(summary, source, asset_name)
_show_only(_result)
set_status("结算完成:%s | 平均 %.0f 分" % [str(summary.get("grade", "D")), float(summary.get("average_score", 0.0))])
func _on_retry() -> void:
# 回到游戏页并重新准备上一轮的动作
_show_only(_game)
func set_status(text: String) -> void:
if _status_label != null:
_status_label.text = text
+144
View File
@@ -0,0 +1,144 @@
class_name UiKit
extends RefCounted
## 轻量 UI 构件工厂。
##
## 本项目的界面**全部用代码构建**,不手写 .tscn:
## .tscn 是带 uid/节点路径的手写格式,在没有编辑器的情况下极易出错且难以验证;
## 用代码构建可以随 GDScript 一起被无头导入与单元测试覆盖。
## scenes/main.tscn 因此只保留一个挂载 main.gd 的根节点。
const COLOR_BG := Color("1b1f2a")
const COLOR_PANEL := Color("232838")
const COLOR_TEXT := Color("e8ecf5")
const COLOR_MUTED := Color("9aa4b8")
const COLOR_ACCENT := Color("4ea1ff")
const COLOR_GOOD := Color("7ae582")
const COLOR_BAD := Color("ff6b6b")
const FONT_TITLE := 30
const FONT_HEADING := 20
const FONT_BODY := 15
const FONT_SMALL := 13
static func apply_root_theme(root: Control) -> void:
root.add_theme_color_override("font_color", COLOR_TEXT)
static func panel(background: Color = COLOR_PANEL, radius: int = 10) -> PanelContainer:
var style := StyleBoxFlat.new()
style.bg_color = background
style.corner_radius_top_left = radius
style.corner_radius_top_right = radius
style.corner_radius_bottom_left = radius
style.corner_radius_bottom_right = radius
style.content_margin_left = 14
style.content_margin_right = 14
style.content_margin_top = 10
style.content_margin_bottom = 10
var node := PanelContainer.new()
node.add_theme_stylebox_override("panel", style)
return node
static func label(text: String, size: int = FONT_BODY, color: Color = COLOR_TEXT) -> Label:
var node := Label.new()
node.text = text
node.add_theme_font_size_override("font_size", size)
node.add_theme_color_override("font_color", color)
return node
static func title(text: String) -> Label:
return label(text, FONT_TITLE, COLOR_TEXT)
static func heading(text: String) -> Label:
return label(text, FONT_HEADING, COLOR_TEXT)
static func muted(text: String, size: int = FONT_SMALL) -> Label:
var node := label(text, size, COLOR_MUTED)
node.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
return node
static func button(text: String, on_pressed: Callable = Callable()) -> Button:
var node := Button.new()
node.text = text
node.custom_minimum_size = Vector2(150, 38)
if on_pressed.is_valid():
node.pressed.connect(on_pressed)
return node
static func hbox(separation: int = 10) -> HBoxContainer:
var node := HBoxContainer.new()
node.add_theme_constant_override("separation", separation)
return node
static func vbox(separation: int = 8) -> VBoxContainer:
var node := VBoxContainer.new()
node.add_theme_constant_override("separation", separation)
return node
static func spacer(size: int = 8) -> Control:
var node := Control.new()
node.custom_minimum_size = Vector2(0, size)
return node
static func expander() -> Control:
var node := Control.new()
node.size_flags_horizontal = Control.SIZE_EXPAND_FILL
node.size_flags_vertical = Control.SIZE_EXPAND_FILL
return node
static func separator() -> HSeparator:
return HSeparator.new()
static func progress_bar(value: float = 0.0) -> ProgressBar:
var node := ProgressBar.new()
node.min_value = 0.0
node.max_value = 100.0
node.value = value
node.show_percentage = false
node.custom_minimum_size = Vector2(0, 18)
return node
static func option_button(items: PackedStringArray, on_selected: Callable = Callable()) -> OptionButton:
var node := OptionButton.new()
for item in items:
node.add_item(item)
if on_selected.is_valid():
node.item_selected.connect(on_selected)
return node
static func line_edit(placeholder: String, text: String = "") -> LineEdit:
var node := LineEdit.new()
node.placeholder_text = placeholder
node.text = text
node.custom_minimum_size = Vector2(320, 34)
return node
static func rich_text() -> RichTextLabel:
var node := RichTextLabel.new()
node.bbcode_enabled = true
node.fit_content = false
node.scroll_active = true
node.size_flags_vertical = Control.SIZE_EXPAND_FILL
node.size_flags_horizontal = Control.SIZE_EXPAND_FILL
return node
static func status_chip(text: String, color: Color) -> Label:
var node := label(text, FONT_SMALL, color)
return node