extends CharacterBody3D class_name Player ## First-person player for Destroyulator. Built entirely in code so Main.gd can spawn it ## where the old fixed Camera3D was and reuse `player.camera` for the crosshair raycast. ## Mouse-captured look, WASD move, Space jump; Esc releases the mouse (click recaptures). ## ## Melee is a `Weapon` resource (stats + the weapon-vs-material matrix + a swing ## archetype) and the arms are a rigged `ViewModel`. Swapping weapon swaps both; movement ## never changes. ## ## The hit does NOT land on the click — it lands at `weapon.contact` through the swing ## animation. That single delay is most of why a sledgehammer feels different from a box ## cutter: you commit, then it connects. ## ## Godot 4.7 GDScript 2.0. No 3.x APIs: move_and_slide() takes no args and reads/writes ## `velocity`; get_gravity() supplies the project default; mouse_mode is a property. @export var move_speed: float = 5.0 @export var accel: float = 12.0 @export var air_accel: float = 3.0 @export var jump_velocity: float = 4.5 @export var mouse_sensitivity: float = 0.0025 # radians per pixel of mouse motion @export var pitch_limit_deg: float = 89.0 @export var eye_height: float = 1.6 var camera: Camera3D # Main reads this for the crosshair raycast var grab: GrabController # the record grab/extract/throw hands var viewmodel: ViewModel # the arms + weapon rig var _head: Node3D var _pitch := 0.0 var _cd_left := 0.0 # --- loadout ------------------------------------------------------------- var _loadout: Array[Weapon] = [] var _slot := 0 var _prev_slot := 0 # --- swing scheduling ---------------------------------------------------- var _queued := false # a click waiting for the cooldown var _swing_left := -1.0 # seconds until this swing's contact frame var _swinging: Weapon = null # --- feel bookkeeping ---------------------------------------------------- var _look_delta := Vector2.ZERO # mouse motion this frame, fed to viewmodel sway var _was_on_floor := true var _fall_speed := 0.0 signal weapon_changed(w: Weapon) signal hit_landed(node: Node, w: Weapon) signal swung(w: Weapon) func _ready() -> void: # --- collision capsule (feet at the body origin) --- var col := CollisionShape3D.new() var cap := CapsuleShape3D.new() cap.radius = 0.35 cap.height = 1.8 col.shape = cap col.position = Vector3(0, 0.9, 0) # capsule center at height/2 so feet sit at origin add_child(col) # --- head (pitch) + camera --- _head = Node3D.new() _head.name = "Head" _head.position = Vector3(0, eye_height, 0) add_child(_head) camera = Camera3D.new() camera.name = "Camera3D" _head.add_child(camera) camera.current = true camera.near = 0.03 # the viewmodel sits ~40 cm out; don't clip it _loadout = Weapon.loadout() viewmodel = ViewModel.new() viewmodel.setup(camera) # parents itself to the camera viewmodel.equip(_loadout[_slot]) _build_crosshair() # the record ritual hands: a child node that owns a hold point under the camera grab = GrabController.new() add_child(grab) grab.setup(camera) Input.mouse_mode = Input.MOUSE_MODE_CAPTURED weapon_changed.emit(_loadout[_slot]) # ---------------------------------------------------------------- loadout func weapon() -> Weapon: return _loadout[_slot] func loadout() -> Array[Weapon]: return _loadout func slot() -> int: return _slot func select(i: int) -> void: if i < 0 or i >= _loadout.size() or i == _slot: return if viewmodel != null and viewmodel.is_swapping(): return _prev_slot = _slot _slot = i _queued = false _swing_left = -1.0 _swinging = null if viewmodel != null: viewmodel.request_swap(_loadout[_slot]) weapon_changed.emit(_loadout[_slot]) func cycle(step: int) -> void: select(posmod(_slot + step, _loadout.size())) func quick_swap() -> void: select(_prev_slot) # ---------------------------------------------------------------- crosshair func _build_crosshair() -> void: var layer := CanvasLayer.new() layer.name = "CrosshairLayer" add_child(layer) var dot := ColorRect.new() dot.color = Color(1, 1, 1, 0.85) dot.anchor_left = 0.5 dot.anchor_top = 0.5 dot.anchor_right = 0.5 dot.anchor_bottom = 0.5 dot.offset_left = -2 dot.offset_top = -2 dot.offset_right = 2 dot.offset_bottom = 2 layer.add_child(dot) # ---------------------------------------------------------------- input func _unhandled_input(event: InputEvent) -> void: if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: var mm := event as InputEventMouseMotion _look_delta += mm.relative * 0.01 # while pulling a disc out, horizontal drag slides the disc (yaw is suspended); # vertical still looks up/down so you're never fully locked. if grab != null and grab.is_extracting(): grab.feed_drag(mm.relative.x) else: rotate_y(-mm.relative.x * mouse_sensitivity) # yaw on the body only _pitch = clamp( _pitch - mm.relative.y * mouse_sensitivity, deg_to_rad(-pitch_limit_deg), deg_to_rad(pitch_limit_deg)) _head.rotation.x = _pitch # pitch on the head only return if event is InputEventMouseButton and event.pressed: var mb := event as InputEventMouseButton if Input.mouse_mode != Input.MOUSE_MODE_CAPTURED: Input.mouse_mode = Input.MOUSE_MODE_CAPTURED # click recaptures; not an attack return match mb.button_index: MOUSE_BUTTON_LEFT: # holding a record? LMB throws the disc / trashes the sleeve, not a swing. if grab != null and grab.is_holding(): grab.primary() else: _queued = true MOUSE_BUTTON_WHEEL_UP: cycle(-1) MOUSE_BUTTON_WHEEL_DOWN: cycle(1) return if event is InputEventKey and event.pressed and not event.echo: var k := (event as InputEventKey).keycode if k == KEY_ESCAPE: Input.mouse_mode = Input.MOUSE_MODE_VISIBLE elif k == KEY_E and grab != null: grab.try_grab() # grab the record under the crosshair elif k == KEY_G and grab != null: grab.drop() # drop / trash whatever's in hand elif k == KEY_Q: quick_swap() elif k >= KEY_1 and k <= KEY_6: select(k - KEY_1) # ---------------------------------------------------------------- physics / movement func _physics_process(delta: float) -> void: _cd_left = max(0.0, _cd_left - delta) # --- start a swing when the cooldown allows --- if _queued and _cd_left <= 0.0 and (viewmodel == null or not viewmodel.is_swapping()): _queued = false var w := weapon() _cd_left = w.cooldown _swinging = w _swing_left = w.swing_time * w.contact if viewmodel != null: viewmodel.start_swing() swung.emit(w) # --- land the hit at the contact frame, not on the click --- if _swing_left >= 0.0: _swing_left -= delta if _swing_left <= 0.0: var w := _swinging _swing_left = -1.0 _swinging = null if w != null: var hit := _strike(w) if hit != null: hit_landed.emit(hit, w) # gravity (only off the floor, or it accumulates and you rocket downward) if not is_on_floor(): velocity += get_gravity() * delta _fall_speed = maxf(_fall_speed, -velocity.y) elif not _was_on_floor: if viewmodel != null: viewmodel.land(_fall_speed) _fall_speed = 0.0 _was_on_floor = is_on_floor() if Input.is_key_pressed(KEY_SPACE) and is_on_floor(): velocity.y = jump_velocity var input_dir := Vector2( float(Input.is_key_pressed(KEY_D)) - float(Input.is_key_pressed(KEY_A)), float(Input.is_key_pressed(KEY_S)) - float(Input.is_key_pressed(KEY_W))) var wish := transform.basis * Vector3(input_dir.x, 0.0, input_dir.y) wish.y = 0.0 if wish.length() > 1.0: wish = wish.normalized() var target := wish * move_speed var a := accel if is_on_floor() else air_accel velocity.x = move_toward(velocity.x, target.x, a * move_speed * delta) velocity.z = move_toward(velocity.z, target.z, a * move_speed * delta) move_and_slide() func _process(dt: float) -> void: if viewmodel != null: var planar := Vector2(velocity.x, velocity.z).length() viewmodel.drive(_look_delta, planar, is_on_floor(), dt) _look_delta = _look_delta.lerp(Vector2.ZERO, clampf(dt * 14.0, 0.0, 1.0)) # ---------------------------------------------------------------- the hit ## Forgiving SPHERE query from the camera centre (the crosshair), so melee doesn't need ## pixel aim. intersect_shape is NOT distance-sorted, so pick the nearest collider. ## Runs from _physics_process, where direct_space_state is valid. func _strike(w: Weapon) -> Node: var space := get_world_3d().direct_space_state if space == null: return null var origin := camera.global_position var dir := -camera.global_transform.basis.z var sphere := SphereShape3D.new() sphere.radius = w.radius var params := PhysicsShapeQueryParameters3D.new() params.shape = sphere params.transform = Transform3D(Basis.IDENTITY, origin + dir * w.reach) params.collide_with_bodies = true params.collide_with_areas = false params.exclude = [get_rid()] var hits := space.intersect_shape(params, 8) if hits.is_empty(): return null var best: Node = null var best_d := INF for h in hits: var c = h.get("collider") if c == null or not (c is Node3D): continue var d: float = (c.global_position - origin).length_squared() if d < best_d: best_d = d best = c if best == null: return null # Smashable extends RigidBody3D, so this branch MUST come first or we'd only shove # props around and never break them. if best is Smashable: var s := best as Smashable s.smash(dir * w.impulse, w.damage_against(s.kind)) elif best is RigidBody3D: var hit_pos: Vector3 = origin + dir * w.reach (best as RigidBody3D).apply_impulse(dir * w.knockback, hit_pos - (best as RigidBody3D).global_position) return best