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 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 _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 _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 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") 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 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) _set_prompt("[LMB] Place %s [R] Rotate" % def.name) 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: _set_prompt(t.interact_prompt(player)) elif def.has("food"): _set_prompt("[RMB] Eat %s" % def.name) else: _set_prompt("") 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 _place_xf = Transform3D(Basis(Vector3.UP, yaw), hit.position) _place_ok = player.get_parent().placement_valid(def, _place_xf) _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(i) 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(player.selected - 1) elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN: player.select(player.selected + 1) if event.is_action_pressed("use_item") and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: if _held_def().has("food"): Actions.eat(player, player.selected) elif event.is_action_pressed("rotate_piece"): _ghost_yaw += deg_to_rad(22.5) 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") else: swing() ## 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 = SWING_TIME 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: _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())