saumwacht-godot/scripts/luneraria/ui/components/portrait_cache.gd
Martin Stickelbroeck 1b540f2a55 G3 (Zwischenstand): neues Ingame-HUD; Projekt wechselt nach Unity
Nicht abgenommen: Nach dem ersten Spieltest wurde entschieden, Champions of
Luneraria nach Unity 6 zu portieren. Dieser Stand ist die Referenz für das
HUD im Unity-Projekt (../luneraria-unity).

- HUD neu aus Komponenten mit eigenem Theme, Porträts aus den echten Modellen
  (einmal gerendert, zwischengespeichert), Lane-Übersicht mit deterministischer
  Bedrohung aus öffentlichem Zustand, Bossgrotte, nächste Welle, Ereignisse,
  Minimap mit zusammengefassten Armeen, Held/Items/Talente, Einheitendetails,
  Angriffschampions, Bereit-Sperre, UI-Größe, Team-Formen statt nur Farben
- Privatsphäre über LuneHudModel: fremdes Gold und fremde Entwürfe erscheinen nie
- Keine Regeländerung: Regeln 91/91, M2 73/73, Netz 43/43; verify_g3 715/715;
  verify_m1 38/38 und verify_m2 14/14 mit angepassten UI-Treibern
- Spieltest bis Welle 8 über die echte Oberfläche, Befunde in G3-HUD.md
- HELDEN-KONZEPT.md: vom Projektinhaber vorgegebene Helden (nicht umgesetzt)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:59:55 +02:00

167 lines
5.9 KiB
GDScript

class_name LunePortraits
extends Node
## Portraits from the real game models. One offscreen SubViewport renders one model at a time
## with the same orthographic three-quarter camera, the same key, fill and rim light and a
## transparent background; every model is framed from its own bounds, so small creatures and
## the Schlundkrone fill the card alike. Each portrait is rendered once and kept as a texture,
## so cards never hold live viewports. Without a display (headless tests) nothing renders and
## callers keep their placeholder.
signal portrait_ready(key: String)
const SMALL: int = 160
const LARGE: int = 400
const YAW: float = .42
const PITCH: float = .2
const Materials = preload("res://scripts/luneraria/view/lune_materials.gd")
static var _shared: LunePortraits
static var textures: Dictionary = {}
var rendered: int = 0
var _viewport: SubViewport
var _stage: Node3D
var _camera: Camera3D
var _queue: Array[String] = []
var _busy: bool = false
var _current: String = ""
static func shared() -> LunePortraits:
if _shared == null or not is_instance_valid(_shared):
_shared = LunePortraits.new()
_shared.name = "LunePortraits"
var tree := Engine.get_main_loop() as SceneTree
tree.root.add_child.call_deferred(_shared)
return _shared
static func key_for(model_id: String, large: bool = false) -> String:
return model_id + ("@L" if large else "")
static func available() -> bool:
return DisplayServer.get_name() != "headless"
## The cached portrait, or null while it is queued (portrait_ready follows).
func portrait(model_id: String, large: bool = false) -> Texture2D:
var key: String = key_for(model_id, large)
if textures.has(key):
return textures[key]
if available() and not _queue.has(key) and key != _current:
_queue.append(key)
return null
func pending() -> int:
return _queue.size() + int(_busy)
func _ready() -> void:
_viewport = SubViewport.new()
_viewport.own_world_3d = true
_viewport.transparent_bg = true
_viewport.msaa_3d = Viewport.MSAA_4X
_viewport.size = Vector2i(SMALL, SMALL)
_viewport.render_target_update_mode = SubViewport.UPDATE_DISABLED
add_child(_viewport)
_stage = Node3D.new()
_viewport.add_child(_stage)
var world := WorldEnvironment.new()
var settings := Environment.new()
settings.background_mode = Environment.BG_CLEAR_COLOR
settings.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
settings.ambient_light_color = Color("a8b4c8")
settings.ambient_light_energy = .62
settings.tonemap_mode = Environment.TONE_MAPPER_ACES
settings.tonemap_white = 4.0
settings.adjustment_enabled = true
settings.adjustment_contrast = 1.08
settings.adjustment_saturation = 1.12
world.environment = settings
_viewport.add_child(world)
for spec: Array in [[Vector3(-38, -32, 0), Color("ffe2bc"), 1.75], [Vector3(-18, 150, 0), Color("9fc4ff"), .9], [Vector3(-8, 40, 0), Color("c8d4e4"), .35]]:
var light := DirectionalLight3D.new()
light.rotation_degrees = spec[0]
light.light_color = spec[1]
light.light_energy = spec[2]
_viewport.add_child(light)
_camera = Camera3D.new()
_camera.projection = Camera3D.PROJECTION_ORTHOGONAL
_camera.current = true
_viewport.add_child(_camera)
func _process(_delta: float) -> void:
if not _busy and not _queue.is_empty():
_render(_queue.pop_front())
func _render(key: String) -> void:
_busy = true
_current = key
var large: bool = key.ends_with("@L")
var model_id: String = key.trim_suffix("@L")
var edge: int = LARGE if large else SMALL
_viewport.size = Vector2i(edge, edge)
var model: Node3D = _instantiate(model_id)
if model == null:
textures[key] = AssetLibrary.icon(model_id)
_busy = false
_current = ""
portrait_ready.emit(key)
return
_stage.add_child(model)
_pose(model)
_frame(model)
_viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
await RenderingServer.frame_post_draw
await RenderingServer.frame_post_draw
var image: Image = _viewport.get_texture().get_image()
model.queue_free()
if image != null and not image.is_empty():
image.generate_mipmaps()
textures[key] = ImageTexture.create_from_image(image)
rendered += 1
else:
textures[key] = AssetLibrary.icon(model_id)
_busy = false
_current = ""
portrait_ready.emit(key)
func _instantiate(model_id: String) -> Node3D:
var model: Node3D = AssetLibrary.instantiate_fighter(model_id) if AssetLibrary.has_model(model_id) else null
if model == null:
return null
if model_id == "schlundkrone":
for surface: GeometryInstance3D in Materials.dress(model):
surface.set_instance_shader_parameter("tint", Materials.team_color(2, "crystal"))
surface.set_instance_shader_parameter("seed", 1.7)
surface.set_instance_shader_parameter("energy", 1.0)
return model
func _pose(model: Node3D) -> void:
if model.has_method("preview"):
model.preview("idle", .4)
elif model.has_method("pose"):
model.pose(.4, false, 0.0, 0.0, 0.0)
## Frames the model's bounds: the same view direction for every portrait, size from bounds.
func _frame(model: Node3D) -> void:
var box: AABB = _bounds(model)
var center: Vector3 = box.get_center()
var radius: float = maxf(.3, box.size.length() * .5)
var direction := Vector3(sin(YAW) * cos(PITCH), sin(PITCH), cos(YAW) * cos(PITCH))
_camera.size = maxf(box.size.y, maxf(box.size.x, box.size.z) * .92) * 1.12
_camera.near = .05
_camera.far = radius * 8.0 + 10.0
_camera.position = center + direction * (radius * 3.0 + 2.0)
_camera.look_at(center, Vector3.UP)
func _bounds(model: Node3D) -> AABB:
if model.has_method("get_aabb"):
var own: AABB = model.get_aabb()
if own.size.length() > .01:
return own
var result := AABB()
var first: bool = true
for node: Node in model.find_children("*", "VisualInstance3D", true, false):
var visual := node as VisualInstance3D
var local: Transform3D = model.global_transform.affine_inverse() * visual.global_transform
var box: AABB = local * visual.get_aabb()
result = box if first else result.merge(box)
first = false
return result if not first else AABB(Vector3(-.5, 0, -.5), Vector3(1, 1.6, 1))