saumwacht-godot/scripts/luneraria/view/lune_camera.gd
Martin Stickelbroeck 8b17563814 G1: Drei-Lane-Layout mit Höhenfeld, Grotte, Aufmarschbereichen und MOBA-Kamera
Top windet sich über das Hochland, Mid quert die Kluft über die Mittelplattform,
Bottom führt durch das Tiefland über die Deltabrücke; Top und Bottom sind exakt
gleich lang, alles bleibt spiegelsymmetrisch. LuneTerrain liefert ein exakt
gespiegeltes Höhenfeld, auf dem Figuren stehen. Bossgrotte als Terrasse auf der
Achse zwischen Top und Mid, Aufmarschbereiche je Lane, perspektivische Kamera
mit Sprungtasten (F2–F4, B, G, H, O, R, Q/E). Bestandsassets wiederverwendet,
neue Formen als Blockout für G2/G4.

Geprüft: verify_g1 443/443, Regeln 91/91, M2 73/73, verify_m1 38/38,
verify_m2 14/14, Netz 43/43, Loopback 12/12, Balance 40 Partien (Seitentausch
20/20), Legacy 209/463. Vorher/Nachher-Aufnahmen und Messungen in G1-KARTE.md.

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

135 lines
5.3 KiB
GDScript

extends Camera3D
## MOBA-style game camera: a perspective three-quarter view whose angle steepens as it
## zooms out (flat and close for fights, steep for the whole map). `zoom` is the visible
## ground height at the focus, the same measure the old orthographic camera used, so lane,
## base and grotto framings keep their meaning. The focus follows the terrain height.
## Blau's base sits bottom-left, Rot's top-right, as on the minimap (yaw 0).
const FOV_DEGREES: float = 34.0
const MIN_ZOOM: float = 11.0
const MAX_ZOOM: float = 100.0
const DEFAULT_ZOOM: float = 34.0
## Pitch at the closest and the widest zoom.
const PITCH_NEAR: float = -48.0
const PITCH_FAR: float = -70.0
const FOCUS_LIMIT: float = 42.0
var focus := Vector3.ZERO
var yaw: float = 0.0
var zoom: float = DEFAULT_ZOOM
var reduced_motion: bool = false
## Terrain height under a ground point; the world sets it (flat without a world).
var ground: Callable = func(_p: Vector2) -> float: return 0.0
var _goal_focus := Vector3.ZERO
var _goal_zoom: float = DEFAULT_ZOOM
var _goal_yaw: float = 0.0
var _dragging: bool = false
func _ready() -> void:
projection = PROJECTION_PERSPECTIVE
fov = FOV_DEGREES
near = .5
far = 700.0
current = true
_goal_focus = focus
_apply()
static func pitch_for(value: float) -> float:
return deg_to_rad(lerpf(PITCH_NEAR, PITCH_FAR, smoothstep(14.0, MAX_ZOOM, value)))
func distance_for(value: float) -> float:
return value / (2.0 * tan(deg_to_rad(fov) * 0.5))
func look_at_point(point: Vector3, new_zoom: float = -1.0, instant: bool = false) -> void:
_goal_focus = Vector3(clampf(point.x, -FOCUS_LIMIT, FOCUS_LIMIT), 0, clampf(point.z, -FOCUS_LIMIT, FOCUS_LIMIT))
if new_zoom > 0.0:
_goal_zoom = clampf(new_zoom, MIN_ZOOM, MAX_ZOOM)
if instant or reduced_motion:
focus = _goal_focus
focus.y = ground.call(Vector2(focus.x, focus.z))
zoom = _goal_zoom
yaw = _goal_yaw
_apply()
## The whole map (both bases, all lanes and the grotto) at once.
func overview(instant: bool = false) -> void:
look_at_point(Vector3.ZERO, 92.0, instant)
## Quarter turns around the focus; R returns to the map-aligned view.
func rotate_step(direction: int) -> void:
_goal_yaw += direction * PI * 0.25
if reduced_motion:
yaw = _goal_yaw
_apply()
func reset_view(point: Vector3, instant: bool = false) -> void:
_goal_yaw = 0.0
look_at_point(point, DEFAULT_ZOOM, instant)
if instant:
yaw = 0.0
_apply()
func _apply() -> void:
var back := Vector3(0, 0, 1).rotated(Vector3.RIGHT, pitch_for(zoom)).rotated(Vector3.UP, yaw)
position = focus + back * distance_for(zoom)
look_at(focus, Vector3.UP)
func _process(delta: float) -> void:
var pan := Vector2(Input.get_axis("ui_left", "ui_right"), Input.get_axis("ui_up", "ui_down"))
if Input.is_physical_key_pressed(KEY_A): pan.x -= 1
if Input.is_physical_key_pressed(KEY_D): pan.x += 1
if Input.is_physical_key_pressed(KEY_W): pan.y -= 1
if Input.is_physical_key_pressed(KEY_S): pan.y += 1
if pan != Vector2.ZERO and not _text_focus():
var right := Vector3(cos(yaw), 0, -sin(yaw))
var forward := Vector3(-sin(yaw), 0, -cos(yaw))
_goal_focus += (right * pan.x - forward * pan.y) * zoom * .9 * delta
_goal_focus.x = clampf(_goal_focus.x, -FOCUS_LIMIT, FOCUS_LIMIT)
_goal_focus.z = clampf(_goal_focus.z, -FOCUS_LIMIT, FOCUS_LIMIT)
var t: float = 1.0 if reduced_motion else minf(1.0, delta * 7.0)
var target_height: float = ground.call(Vector2(_goal_focus.x, _goal_focus.z))
focus = focus.lerp(Vector3(_goal_focus.x, target_height, _goal_focus.z), t)
zoom = lerpf(zoom, _goal_zoom, t)
yaw = lerp_angle(yaw, _goal_yaw, t)
_apply()
func _text_focus() -> bool:
var owner: Control = get_viewport().gui_get_focus_owner()
return owner is LineEdit or owner is TextEdit
## Ground outline of the current view (four corners on the focus plane) for the minimap.
func footprint() -> PackedVector2Array:
var result := PackedVector2Array()
var viewport: Viewport = get_viewport()
if viewport == null:
return result
var extent: Vector2 = viewport.get_visible_rect().size
for corner: Vector2 in [Vector2.ZERO, Vector2(extent.x, 0), extent, Vector2(0, extent.y)]:
var origin: Vector3 = project_ray_origin(corner)
var direction: Vector3 = project_ray_normal(corner)
if direction.y > -0.02:
direction.y = -0.02
var hit: Vector3 = origin + direction * ((focus.y - origin.y) / direction.y)
result.append(Vector2(hit.x, hit.z))
return result
## World input only; the HUD consumes its own clicks before they reach here.
func handle_world_input(event: InputEvent) -> bool:
if event is InputEventMouseButton and event.pressed:
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
_goal_zoom = maxf(MIN_ZOOM, _goal_zoom - maxf(2.5, _goal_zoom * .09))
return true
if event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
_goal_zoom = minf(MAX_ZOOM, _goal_zoom + maxf(2.5, _goal_zoom * .09))
return true
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_MIDDLE:
_dragging = event.pressed
return true
if event is InputEventMouseMotion and _dragging:
var right := Vector3(cos(yaw), 0, -sin(yaw))
var forward := Vector3(-sin(yaw), 0, -cos(yaw))
_goal_focus -= (right * event.relative.x - forward * event.relative.y) * zoom / 700.0
_goal_focus.x = clampf(_goal_focus.x, -FOCUS_LIMIT, FOCUS_LIMIT)
_goal_focus.z = clampf(_goal_focus.z, -FOCUS_LIMIT, FOCUS_LIMIT)
return true
return false