extends Node3D ## The player's hands: what you're looking at, what you're holding, and what ## clicking does. Lives under the camera. ## ## - Looking at something interactable shows its prompt (E to interact). ## - LMB with a tool swings it; the hit lands mid-swing via Actions.harvest_hit. ## - Holding a placeable shows a ghost preview: green = valid, red = blocked. ## R rotates, LMB places (Actions.place). ## - Keys 1-8 / mouse wheel pick the hotbar slot. signal prompt_changed(text: String) const Campfire = preload("res://scripts/building/campfire.gd") const BuildPiece = preload("res://scripts/building/build_piece.gd") const ItemMeshesScript = preload("res://scripts/items/item_meshes.gd") const REACH := 3.2 const PLACE_REACH := 6.0 const SWING_TIME := 0.5 var player: CharacterBody3D var _ray: RayCast3D var _view: MeshInstance3D ## held item, first-person var _view_rest := Transform3D() var _swing_cd := 0.0 var _ghost: MeshInstance3D var _ghost_ok_mat: StandardMaterial3D var _ghost_bad_mat: StandardMaterial3D var _ghost_yaw := 0.0 var _place_xf := Transform3D() var _place_ok := false var _place_reason := "" var _rest_default := Transform3D() var _rest_bow := Transform3D() var _drawing := false var _draw_t := 0.0 var _prompt := "" var _held_shown := "~" var _torch_light: OmniLight3D var _torch_flame: CPUParticles3D var _t := 0.0 func _ready() -> void: _ray = RayCast3D.new() _ray.target_position = Vector3(0, 0, -REACH) _ray.collision_mask = 1 | 2 | 8 # world, interactables, animals _ray.collide_with_areas = true _ray.add_exception(player) add_child(_ray) _view = MeshInstance3D.new() _view.material_override = ItemDB.item_material() _view.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF _view_rest = Transform3D(Basis.from_euler(Vector3(deg_to_rad(-25), deg_to_rad(-10), deg_to_rad(-20))), Vector3(0.36, -0.42, -0.62)) _view.transform = _view_rest _rest_default = _view_rest _rest_bow = Transform3D(Basis.from_euler(Vector3(deg_to_rad(-8), deg_to_rad(80), deg_to_rad(-12))), Vector3(0.3, -0.3, -0.6)) add_child(_view) _ghost_ok_mat = _ghost_material(Color(0.3, 1.0, 0.4, 0.45)) _ghost_bad_mat = _ghost_material(Color(1.0, 0.25, 0.2, 0.45)) _ghost = MeshInstance3D.new() _ghost.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF _ghost.visible = false _ghost.top_level = true add_child(_ghost) _torch_light = OmniLight3D.new() _torch_light.light_color = Color(1.0, 0.65, 0.35) _torch_light.omni_attenuation = 1.3 _torch_light.shadow_enabled = true _torch_light.position = Vector3(0, 0.62, 0) _torch_light.visible = false _view.add_child(_torch_light) _torch_flame = Campfire._particles( "TorchFlame", 14, 0.4, Vector3(0, 0.58, 0), 0.2, 0.5, 0.25, 0.5, Campfire._gradient([ [0.0, Color(1.0, 0.8, 0.35, 0.0)], [0.2, Color(1.0, 0.6, 0.2, 0.8)], [1.0, Color(0.6, 0.1, 0.02, 0.0)]]), true, 0.12, 0.03) _torch_flame.local_coords = false _torch_flame.emitting = false _view.add_child(_torch_flame) player.inventory.changed.connect(_refresh_held) player.selected_changed.connect(_refresh_held) _refresh_held() static func _ghost_material(c: Color) -> StandardMaterial3D: var m := StandardMaterial3D.new() m.albedo_color = c m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED m.no_depth_test = false return m func _held_def() -> Dictionary: return ItemDB.get_item(player.held_item_id()) func _refresh_held() -> void: var id: String = player.held_item_id() if id == _held_shown: return _held_shown = id var def := _held_def() _view.visible = not id.is_empty() and not def.has("place") # A bow is held side-on, so you see its curve and string. _view_rest = _rest_bow if def.has("bow") else _rest_default _view.transform = _view_rest if _view.visible: _view.mesh = ItemDB.mesh_for(id) var s := 0.85 if def.category in ["tool", "weapon"] else 0.6 _view.scale = Vector3.ONE * s if def.has("place"): _ghost.mesh = ItemDB.mesh_for(id) _ghost_yaw = 0.0 else: _ghost.visible = false # Torch: a flickering light at the head of the held torch. var light: Dictionary = def.get("light", {}) _torch_light.visible = not light.is_empty() _torch_flame.emitting = not light.is_empty() if not light.is_empty(): _torch_light.omni_range = float(light.get("range", 8.0)) _torch_light.light_energy = float(light.get("energy", 1.5)) ## 0..1 warmth from what's held (a torch). func held_heat() -> float: return float(_held_def().get("light", {}).get("heat", 0.0)) func _set_prompt(t: String) -> void: if t != _prompt: _prompt = t prompt_changed.emit(t) func _target() -> Object: if not _ray.is_colliding(): return null var c := _ray.get_collider() if c != null and c.has_meta("owner_piece"): c = c.get_meta("owner_piece") # a door's moving panel if c == null or not c.has_method("interact_prompt"): return null if c.get("depleted") == true: return null return c func _physics_process(delta: float) -> void: _swing_cd = maxf(_swing_cd - delta, 0.0) if player.ui_open: _ghost.visible = false _set_prompt("") return var def := _held_def() if def.has("place"): _update_ghost(def) var why := "" if _place_ok or _place_reason.is_empty() else " (%s)" % _place_reason _set_prompt("[LMB] Place %s [R] Rotate%s" % [def.name, why]) return _ghost.visible = false if _torch_light.visible: _t += delta _torch_light.light_energy = float(def.get("light", {}).get("energy", 1.5)) * (0.85 + 0.15 * sin(_t * 17.0) * sin(_t * 5.3)) var t := _target() if t != null: var p: String = t.interact_prompt(player) if t.is_in_group("piece") and t.item_id != "grave": p += " [X] Dismantle" # Online: someone else's piece says whose it is (the server decides # whether you may use it, by its rules). var owner := String(t.get_meta("owner", "")) if not owner.is_empty() and Net.state == "connected" and owner != Net.get_identity().player_id: p = "%s's ยท %s" % [String(t.get_meta("owner_name", "someone")), p] _set_prompt(p) elif def.has("food"): _set_prompt("[RMB] Eat %s" % def.name) elif def.has("wear"): _set_prompt("[RMB] Wear %s" % def.name) elif def.has("bow"): var n: int = Actions.ammo_count(player, def.bow) _set_prompt("Drawing %d%%" % roundi(draw_power() * 100.0) if _drawing else "[hold LMB] Draw (%d arrows)" % n) elif def.get("weapon", {}).has("throw"): _set_prompt("[LMB] Thrust [RMB] Throw") elif def.has("fishing"): var b: Node3D = player.get_parent().bobber if b == null or not is_instance_valid(b): _set_prompt("[LMB] Cast into the lake") else: match b.state: "biting": _set_prompt("Bite! [LMB] Reel in now") "floating": _set_prompt("Waiting for a bite... [LMB] Reel in") _: _set_prompt("[LMB] Reel in") else: _set_prompt("") # The line runs from the rod tip; switching away from the rod reels in. var bob: Node3D = player.get_parent().bobber if bob != null and is_instance_valid(bob): if def.has("fishing"): bob.tip = rod_tip() else: player.get_parent().end_fishing() if _drawing: if not def.has("bow"): _drawing = false # switched away mid-draw _view.transform = _view_rest else: _draw_t += delta var p := draw_power() # Pull the bow back toward you as it's drawn. _view.transform = Transform3D(_view_rest.basis, _view_rest.origin.lerp(Vector3(0.08, -0.22, -0.5), p)) func _update_ghost(def: Dictionary) -> void: var cam := get_parent() as Camera3D var from := cam.global_position var to := from - cam.global_basis.z * PLACE_REACH var q := PhysicsRayQueryParameters3D.create(from, to, 1, [player.get_rid()]) var hit := cam.get_world_3d().direct_space_state.intersect_ray(q) if hit.is_empty(): _ghost.visible = false _place_ok = false return var yaw := player.rotation.y + _ghost_yaw # piece's front (+Z) faces the player if BuildPiece.is_build(def): # Building pieces snap to what's already built (build_system.gd). var res: Dictionary = player.get_parent().build.placement(def, hit, yaw) _place_xf = res.xf _place_ok = res.ok _place_reason = res.reason else: _place_xf = Transform3D(Basis(Vector3.UP, yaw), hit.position) _place_ok = player.get_parent().placement_valid(def, _place_xf) _place_reason = "" if _place_ok else "blocked or too steep" _ghost.global_transform = _place_xf _ghost.material_override = _ghost_ok_mat if _place_ok else _ghost_bad_mat _ghost.visible = true func _unhandled_input(event: InputEvent) -> void: if player.ui_open: return for i in range(8): if event.is_action_pressed("hotbar_%d" % (i + 1)): player.select_in_row(i) return if event.is_action_pressed("row_up"): player.change_row(-1) return if event.is_action_pressed("row_down"): player.change_row(1) return if event.is_action_pressed("drop"): Actions.drop(player, player.selected, 1 if Input.is_key_pressed(KEY_SHIFT) else 0) return if event is InputEventMouseButton and event.pressed and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: if event.button_index == MOUSE_BUTTON_WHEEL_UP: player.select_in_row(player.selected % player.HOTBAR - 1) elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN: player.select_in_row(player.selected % player.HOTBAR + 1) if event.is_action_released("attack") and _drawing: _release_bow() return if event.is_action_pressed("use_item") and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: var d := _held_def() if d.has("food"): Actions.eat(player, player.selected) elif d.has("wear"): Actions.wear(player, player.selected) elif d.get("weapon", {}).has("throw"): _throw() elif event.is_action_pressed("rotate_piece"): # Building pieces turn in quarter turns (they snap to a grid). _ghost_yaw += deg_to_rad(90.0 if BuildPiece.is_build(_held_def()) else 22.5) elif event.is_action_pressed("dismantle"): var t := _target() if t != null and t.is_in_group("piece"): Actions.dismantle(player, t) elif event.is_action_pressed("interact"): var t := _target() if t != null and t.has_method("interact"): t.interact(player) elif event.is_action_pressed("attack") and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: if _held_def().has("place"): if _place_ok: Actions.place(player, player.selected, _place_xf, player.get_parent()) else: Actions.notify.emit("Can't place that here") elif _held_def().has("bow"): _start_draw() elif _held_def().has("fishing"): _fish_click() else: swing() # --- fishing ---------------------------------------------------------------------- func _fish_click() -> void: var w: Node3D = player.get_parent() if w.bobber != null and is_instance_valid(w.bobber): Actions.reel(player, w) return if _swing_cd > 0.0: return var a := _aim() if Actions.cast_line(player, rod_tip(), a[1], w): _swing_cd = 0.6 var tw := create_tween() var back := Transform3D(_view_rest.basis.rotated(Vector3.RIGHT, deg_to_rad(40)), _view_rest.origin) tw.tween_property(_view, "transform", back, 0.12) tw.tween_property(_view, "transform", _view_rest, 0.25).set_trans(Tween.TRANS_SINE) ## World position of the held rod's tip (where the line starts). func rod_tip() -> Vector3: return _view.global_transform * Vector3(0, ItemMeshesScript.ROD_TIP, 0) # --- bow and spear -------------------------------------------------------------- func _aim() -> Array: var cam := get_parent() as Camera3D var fwd := -cam.global_basis.z return [cam.global_position + fwd * 0.5, fwd] func _start_draw() -> void: if _drawing or _swing_cd > 0.0 or not Actions.can_draw(player): return _drawing = true _draw_t = 0.0 func draw_power() -> float: var bow: Dictionary = _held_def().get("bow", {}) return clampf(_draw_t / float(bow.get("draw", 1.0)), 0.0, 1.0) if _drawing else 0.0 func _release_bow() -> void: var p := draw_power() _drawing = false _view.transform = _view_rest if p < 0.15: return # barely drawn: let the string down, nothing fired var a := _aim() Actions.shoot(player, a[0], a[1], p, player.get_parent()) _swing_cd = 0.3 func _throw() -> void: if _swing_cd > 0.0: return var a := _aim() if Actions.throw_spear(player, player.selected, a[0] + Vector3(0, -0.05, 0), (a[1] as Vector3) + Vector3(0, 0.06, 0), player.get_parent()): _swing_cd = 0.6 ## Swings the held item (or fist). The hit is applied at the peak of the swing. func swing() -> void: if _swing_cd > 0.0: return _swing_cd = float(Actions.melee_stats(player).cooldown) get_tree().create_timer(0.12).timeout.connect(func(): Sfx.me("swing")) # (as it comes down) var tw := create_tween() var wind := Transform3D(_view_rest.basis.rotated(Vector3.RIGHT, deg_to_rad(35)), _view_rest.origin + Vector3(0, 0.08, 0.05)) var strike := Transform3D(_view_rest.basis.rotated(Vector3.RIGHT, deg_to_rad(-70)), _view_rest.origin + Vector3(-0.05, -0.05, -0.15)) tw.tween_property(_view, "transform", wind, 0.1).set_ease(Tween.EASE_OUT) tw.tween_property(_view, "transform", strike, 0.1).set_ease(Tween.EASE_IN) tw.tween_callback(_apply_hit) tw.tween_property(_view, "transform", _view_rest, 0.25).set_trans(Tween.TRANS_SINE) func _apply_hit() -> void: # Animals first: a short box sweep in front of the camera, so you don't # need pixel-perfect aim at a running rabbit. var animal := melee_target() if animal != null and animal.has_meta("avatar_peer"): Actions.hit_player(player, int(animal.get_meta("avatar_peer"))) return if animal != null: Actions.melee(player, animal) return _ray.force_raycast_update() if not _ray.is_colliding(): return var c := _ray.get_collider() if c != null and c.is_in_group("harvestable"): Actions.harvest_hit(player, c, _ray.get_collision_point()) ## The nearest living animal inside the melee sweep, or null. func melee_target() -> Node3D: var cam := get_parent() as Camera3D var reach: float = Actions.melee_stats(player).reach # Tall enough (down to your feet when looking level) to catch a rabbit. var box := BoxShape3D.new() box.size = Vector3(0.9, 2.2, reach) var q := PhysicsShapeQueryParameters3D.new() q.shape = box q.transform = Transform3D(cam.global_basis, cam.global_position - cam.global_basis.z * reach * 0.5 - cam.global_basis.y * 0.5) # Online with PvP on, other players' avatars too (their hitbox, layer 16). var pvp: bool = Net.state == "connected" and bool(Net.server_info.get("pvp", false)) q.collision_mask = 8 | (16 if pvp else 0) var best: Node3D = null var best_d := INF for h in cam.get_world_3d().direct_space_state.intersect_shape(q, 8): var c: Node3D = h.collider if c.is_in_group("creature") or c.has_meta("avatar_peer"): var d := c.global_position.distance_to(cam.global_position) if d < best_d: best_d = d best = c return best