class_name DoorMover
extends AnimatableBody3D
## A Quake func_door materialised as an animated MODEL (not a per-frame SDF edit — see the
## models-vs-dc_terrain review: models are cheaper, more robust, and Godot's AnimatableBody3D
## carries/pushes the player for free). Slides open along the Quake move direction when the
## local player approaches, waits `wait_time`, then closes (unless wait == -1). Crush damage
## and button/targetname triggering are deferred to a later gameplay session — for now every
## door opens on proximity so the map is traversable.
var closed_pos: Vector3 = Vector3.ZERO
var open_pos: Vector3 = Vector3.ZERO
var move_speed: float = 4.0 # world units/sec
var wait_time: float = 3.0 # seconds open before auto-close; < 0 = stay open
var locked: bool = false # key-required or targetname/event door → never opens on touch
var _travel: float = 1.0 # world distance closed→open (normalises the interp speed)
var _want_open: bool = false
var _t: float = 0.0 # 0 = closed, 1 = open
var _close_timer: float = 0.0
static func create(entry: Dictionary, tex: Texture2D) -> DoorMover:
var origin: Vector3 = entry.get("origin", Vector3.ZERO)
var parts: Array = entry.get("parts", [])
var mdir: Vector3 = entry.get("move_dir", Vector3.UP)
var dist: float = float(entry.get("move_distance", 0.0))
var d := DoorMover.new()
d.name = "Door_%s" % String(entry.get("targetname", "prox"))
d.closed_pos = origin
d.open_pos = origin + mdir * dist
d.move_speed = maxf(float(entry.get("speed", 4.0)), 0.1)
d.wait_time = float(entry.get("wait", 3.0))
d.locked = bool(entry.get("locked", false))
d._travel = maxf(dist, 0.01)
d.position = origin
# sync_to_physics = true RESETS the body transform to (0,0,0) on tree-entry (it expects to be
# driven by the physics server) — the door then flickers/sticks at the origin. false keeps our
# set position authoritative; the body still collides with + pushes the player via move.
d.sync_to_physics = false
var base_mat: StandardMaterial3D = null
if tex != null:
base_mat = StandardMaterial3D.new()
base_mat.albedo_texture = tex
base_mat.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST # pixelated, like the terrain
base_mat.uv1_triplanar = true # box UVs stretch; triplanar keeps the tiling sane
# One mesh + collision box per brush (offsets relative to the door origin) so a multi-brush
# door keeps its shape and all parts move together. Track the combined box for the trigger.
var lmin := Vector3(INF, INF, INF)
var lmax := Vector3(-INF, -INF, -INF)
for p in parts:
var off: Vector3 = p.get("offset", Vector3.ZERO)
var half: Vector3 = p.get("half", Vector3.ONE)
lmin = lmin.min(off - half)
lmax = lmax.max(off + half)
var box := BoxMesh.new()
box.size = half * 2.0
var mi := MeshInstance3D.new()
mi.mesh = box
mi.position = off
if base_mat != null:
var m := base_mat.duplicate()
m.uv1_scale = Vector3.ONE / maxf(0.5, half.length())
mi.material_override = m
d.add_child(mi)
var cs := CollisionShape3D.new()
var shape := BoxShape3D.new()
shape.size = half * 2.0
cs.shape = shape
cs.position = off
d.add_child(cs)
# Proximity trigger: an Area a couple metres larger than the whole door opens it.
var area := Area3D.new()
area.name = "Proximity"
area.collision_mask = 0xFFFFF
var acs := CollisionShape3D.new()
var ashape := BoxShape3D.new()
ashape.size = (lmax - lmin) + Vector3(2.0, 2.0, 2.0)
acs.shape = ashape
acs.position = (lmin + lmax) * 0.5
area.add_child(acs)
d.add_child(area)
d._area = area
return d
var _area: Area3D = null
func _ready() -> void:
position = closed_pos # assert the closed position on spawn (frame-0 correctness)
if _area != null:
_area.body_entered.connect(_on_proximity_enter)
_area.body_exited.connect(_on_proximity_exit)
func _on_proximity_enter(body: Node) -> void:
# Key/targetname (event) doors never open on touch — they wait for a key or a trigger we
# don't have yet, so they stay in their default CLOSED position (Quake doors.qc model).
if locked:
return
if body is CharacterBody3D:
_want_open = true
_close_timer = 0.0 # cancel any pending close
func _on_proximity_exit(body: Node) -> void:
# wait < 0 → the door stays open forever (Quake wait -1). Otherwise start the countdown.
if body is CharacterBody3D and wait_time >= 0.0:
_close_timer = wait_time
func _physics_process(delta: float) -> void:
if _close_timer > 0.0:
_close_timer -= delta
if _close_timer <= 0.0:
_want_open = false
var target := 1.0 if _want_open else 0.0
var step := (move_speed / _travel) * delta
_t = move_toward(_t, target, step)
# Assert the position EVERY physics frame: sync_to_physics AnimatableBody3D is driven by
# the physics server, so an unset frame leaves the door at the physics origin, not closed.
position = closed_pos.lerp(open_pos, _t)