391 lines
14 KiB
GDScript
391 lines
14 KiB
GDScript
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(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_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
|