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>
135 lines
5.3 KiB
GDScript
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
|