saumwacht-godot/tools/model_gallery.gd
Martin Stickelbroeck c2b447610c Baseline: Saumwacht 0.15.0 (Phase 20A) vor dem Umbau zu Champions of Luneraria
Unveränderter Ausgangsstand inklusive Masterprompt und Konzeptbildern
unter assets/. .godot/ und docs/screenshots/ bleiben laut .gitignore
unversioniert; letztere sind unter ../backups gesichert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:00:36 +02:00

215 lines
8.7 KiB
GDScript

extends Control
## Standalone Phase 4 review. F6, or run this scene with -- --capture.
const STAGE: Script = preload("res://tools/model_gallery_stage.gd")
const MANIFEST: String = "res://assets/phase4_manifest.json"
const FACTIONS: Dictionary = {
"all": "Alle Modelle", "glutpakt": "Glutpakt", "nietenkonvent": "Nietenkonvent",
"nebelchor": "Nebelchor", "wurzelbund": "Wurzelbund", "neutral": "Söldner & Arbeiter",
"environment": "Spielfeld & Saumkerne", "rift": "Risskreaturen"
}
const ROLES: Dictionary = {
"melee": "Nahkampf", "tank": "Tank", "ranged": "Fernkampf",
"artillery": "Artillerie", "support": "Unterstützung", "mercenary": "Söldner",
"enemy": "Wellengegner", "worker": "Arbeiter", "lane": "Lane-Segment", "core": "Saumkern"
}
var _assets: Array[Dictionary] = []
var _filtered: Array[Dictionary] = []
var _icon_paths: Dictionary = {}
var _faction_select: OptionButton
var _asset_select: OptionButton
var _title: Label
var _details: Label
var _icon: TextureRect
var _reference_note: Label
var _auto_rotate: CheckButton
var _view: Dictionary = {}
func _ready() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
if "--compare" in OS.get_cmdline_user_args():
var comparison_script: Script = load("res://tools/model_reference_sheet.gd") as Script
add_child(comparison_script.new())
return
if "--capture" in OS.get_cmdline_user_args():
var capture_script: Script = load("res://tools/model_contact_sheet.gd") as Script
var capture: Node = capture_script.new() as Node
add_child(capture)
return
_load_catalog()
_build_interface()
_filter_assets(0)
func _load_catalog() -> void:
if not FileAccess.file_exists(MANIFEST):
push_error("Phase 4 manifest missing. Export and import models first.")
return
var data: Variant = JSON.parse_string(FileAccess.get_file_as_string(MANIFEST))
if data is Dictionary:
for value: Variant in data.get("assets", []):
if value is Dictionary:
_assets.append(value)
var icon_manifest: String = "res://assets/phase3_manifest.json"
if FileAccess.file_exists(icon_manifest):
var icons: Variant = JSON.parse_string(FileAccess.get_file_as_string(icon_manifest))
if icons is Dictionary:
for value: Variant in icons.get("assets", []):
if value is Dictionary:
_icon_paths[str(value.get("id", ""))] = str(value.get("resource_path", ""))
func _build_interface() -> void:
var backdrop: ColorRect = ColorRect.new()
backdrop.color = Color("11151b")
backdrop.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
backdrop.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(backdrop)
var margin: MarginContainer = MarginContainer.new()
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
for edge: String in ["left", "top", "right", "bottom"]:
margin.add_theme_constant_override("margin_" + edge, 24)
add_child(margin)
var layout: VBoxContainer = VBoxContainer.new()
layout.add_theme_constant_override("separation", 18)
margin.add_child(layout)
var heading: Label = _label("SAUMWACHT / 3D-WERKSTATT", 27, Color("ddc898"))
layout.add_child(heading)
layout.add_child(_label("%d Modelle · Glutpakt und Risskreaturen in hoher Detailstufe" % _assets.size(), 17, Color("a8b0bd")))
var body: HBoxContainer = HBoxContainer.new()
body.size_flags_vertical = Control.SIZE_EXPAND_FILL
body.add_theme_constant_override("separation", 24)
layout.add_child(body)
var sidebar: VBoxContainer = VBoxContainer.new()
sidebar.custom_minimum_size.x = 320
sidebar.add_theme_constant_override("separation", 14)
body.add_child(sidebar)
sidebar.add_child(_label("FRAKTION", 13, Color("c4b58f")))
_faction_select = OptionButton.new()
_faction_select.custom_minimum_size.y = 42
_faction_select.add_theme_font_size_override("font_size", 18)
for key: String in FACTIONS:
_faction_select.add_item(FACTIONS[key])
_faction_select.set_item_metadata(_faction_select.item_count - 1, key)
_faction_select.item_selected.connect(_filter_assets)
sidebar.add_child(_faction_select)
sidebar.add_child(_label("MODELL", 13, Color("c4b58f")))
_asset_select = OptionButton.new()
_asset_select.custom_minimum_size.y = 42
_asset_select.add_theme_font_size_override("font_size", 17)
_asset_select.fit_to_longest_item = false
_asset_select.item_selected.connect(_select_asset)
sidebar.add_child(_asset_select)
_title = _label("", 24, Color("f1e8d6"))
_title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
sidebar.add_child(_title)
_details = _label("", 16, Color("b8bfca"))
_details.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
sidebar.add_child(_details)
_icon = TextureRect.new()
_icon.custom_minimum_size = Vector2(256, 200)
_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
_icon.size_flags_vertical = Control.SIZE_EXPAND_FILL
sidebar.add_child(_icon)
_reference_note = _label("Iconreferenz · Phase 3", 14, Color("8e99aa"))
sidebar.add_child(_reference_note)
_auto_rotate = CheckButton.new()
_auto_rotate.text = "Automatisch drehen"
_auto_rotate.add_theme_font_size_override("font_size", 16)
sidebar.add_child(_auto_rotate)
var reset: Button = Button.new()
reset.text = "Ansicht zurücksetzen"
reset.custom_minimum_size.y = 38
reset.pressed.connect(_reset_view)
sidebar.add_child(reset)
var preview_box: PanelContainer = PanelContainer.new()
preview_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
preview_box.size_flags_vertical = Control.SIZE_EXPAND_FILL
body.add_child(preview_box)
_view = STAGE.create(preview_box, Vector2(520, 480))
var container: SubViewportContainer = _view["container"] as SubViewportContainer
container.gui_input.connect(_on_preview_input)
layout.add_child(_label("Ziehen: Modell drehen · Mausrad: Zoom · Originalmodelle bleiben unverändert", 15, Color("939dab")))
func _label(text: String, font_size: int, color: Color) -> Label:
var result: Label = Label.new()
result.text = text
result.add_theme_font_size_override("font_size", font_size)
result.add_theme_color_override("font_color", color)
return result
func _filter_assets(index: int) -> void:
_filtered.clear()
_asset_select.clear()
var faction: String = str(_faction_select.get_item_metadata(index))
for asset: Dictionary in _assets:
var matches: bool = faction == "all" or str(asset.get("faction", "")) == faction
if faction == "environment":
matches = str(asset.get("role", "")) in ["lane", "core"]
if matches:
_filtered.append(asset)
_asset_select.add_item(str(asset["id"]).to_upper() + " · " + str(asset["name"]))
if not _filtered.is_empty():
_asset_select.select(0)
_select_asset(0)
else:
_title.text = "Keine Modelle vorhanden"
_details.text = "Blender-Export und Godot-Import zuerst ausführen."
_icon.texture = null
func _select_asset(index: int) -> void:
if index < 0 or index >= _filtered.size():
return
var asset: Dictionary = _filtered[index]
_title.text = str(asset["name"])
var role: String = str(asset.get("role", ""))
var bounds: Dictionary = asset.get("bounds_godot", {})
var low: Array = bounds.get("min", [0, 0, 0])
var high: Array = bounds.get("max", [0, 0, 0])
var dimensions: Vector3 = Vector3(float(high[0])-float(low[0]), float(high[1])-float(low[1]), float(high[2])-float(low[2]))
_details.text = "%s · %s\n%d Dreiecke\nB %.2f m · H %.2f m · T %.2f m" % [
str(asset["id"]).to_upper(), str(ROLES.get(role, role)), int(asset.get("triangles", 0)),
dimensions.x, dimensions.y, dimensions.z]
var icon_id: String = str(asset["id"])
if icon_id == "worker":
icon_id = "worker_funkenleser"
var icon_path: String = str(_icon_paths.get(icon_id, ""))
_icon.texture = load(icon_path) as Texture2D if not icon_path.is_empty() else null
_reference_note.visible = _icon.texture != null
if not STAGE.show_asset(_view, asset):
_details.text += "\nModell konnte nicht geladen werden."
_reset_view()
func _process(delta: float) -> void:
if _auto_rotate != null and _auto_rotate.button_pressed and not _view.is_empty():
var turntable: Node3D = _view["turntable"] as Node3D
turntable.rotate_y(delta * 0.38)
func _reset_view() -> void:
if _view.is_empty():
return
var turntable: Node3D = _view["turntable"] as Node3D
var camera: Camera3D = _view["camera"] as Camera3D
turntable.rotation = Vector3.ZERO
camera.size = 2.9
func _on_preview_input(event: InputEvent) -> void:
var camera: Camera3D = _view["camera"] as Camera3D
var turntable: Node3D = _view["turntable"] as Node3D
if event is InputEventMouseButton and event.pressed:
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
camera.size = clampf(camera.size * 0.90, 1.6, 5.5)
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
camera.size = clampf(camera.size * 1.10, 1.6, 5.5)
if event is InputEventMouseMotion and Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT):
_auto_rotate.button_pressed = false
turntable.rotate_y(event.relative.x * 0.008)