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>
167 lines
5.9 KiB
GDScript
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))
|