Files
Emberwild/scripts/world/resource_node.gd
Paul Bailey 2b1afc86ba Tool prompts explain how to craft the missing tool
'Tree (needs a axe)' gave new players no way forward. The prompt now
shows the recipe (Tab to craft from 2 stick, 2 flint), or tells you to
select the tool if it's already in your bag.
2026-11-04 00:37:04 +00:00

185 lines
6.4 KiB
GDScript

extends StaticBody3D
## A harvestable tree or boulder.
##
## The visible mesh is one instance inside a MultiMesh batch (props.gd), so this
## body only holds collision plus a reference to its instance. Hits shake or
## shrink that instance. When depleted the instance is hidden; a tree is
## replaced by a falling copy and a stump.
##
## Game rules (damage, drops) run through Actions.harvest_hit, which calls hit().
const ProcMesh = preload("res://scripts/world/proc_mesh.gd")
var uid := "" ## stable id for saves (see props.gd)
var kind := "tree" ## "tree" | "rock"
var tool_kind := "axe"
var min_tier := 1
var max_hp := 5.0
var hp := 5.0
var drops_per_hit: Dictionary = {} ## id -> count, given on every hit
var drops_final: Dictionary = {} ## id -> [min, max], given when depleted
var depleted := false
var chip_color := Color(0.6, 0.45, 0.28)
var mmi: MultiMeshInstance3D ## batch holding the visual
var index := -1 ## instance index in that batch
var base_xf := Transform3D()
var stump_material: Material
var rng := RandomNumberGenerator.new()
var _shake := 0.0
var _shake_axis := Vector3.RIGHT
func display_name() -> String:
return "Tree" if kind == "tree" else "Boulder"
func interact_prompt(player: Node) -> String:
var tool: Dictionary = ItemDB.get_item(player.held_item_id()).get("tool", {})
var verb := "Chop" if kind == "tree" else "Mine"
if tool.get("kind", "") == tool_kind:
return "%s [LMB] %s" % [display_name(), verb]
# Tell new players how to get the tool, not just that they lack it.
var tool_id := "stone_" + tool_kind
var r: Dictionary = ItemDB.recipes.get(tool_id, {})
var parts: Array[String] = []
for id in r.get("inputs", {}):
parts.append("%d %s" % [int(r.inputs[id]), ItemDB.item_name(id).to_lower()])
if player.inventory.count(tool_id) > 0:
return "%s (select your %s on the hotbar)" % [display_name(), ItemDB.item_name(tool_id)]
return "%s (needs a %s: Tab to craft from %s)" % [display_name(), ItemDB.item_name(tool_id), ", ".join(parts)]
## Applies one hit. Returns the items earned by this hit.
func hit(damage: float, point: Vector3, from: Vector3) -> Dictionary:
var out := {}
hp -= damage
for id in drops_per_hit:
out[id] = drops_per_hit[id]
_burst(point, chip_color, 14)
if hp <= 0.0:
depleted = true
for id in drops_final:
var r: Array = drops_final[id]
out[id] = out.get(id, 0) + rng.randi_range(int(r[0]), int(r[1]))
_deplete(from)
else:
_shake = 1.0
var away := (global_position - from)
away.y = 0.0
_shake_axis = away.normalized().cross(Vector3.UP) if away.length() > 0.01 else Vector3.RIGHT
if kind == "rock":
_set_instance(_rock_xf())
return out
func _process(delta: float) -> void:
if _shake <= 0.0 or depleted:
return
_shake = maxf(_shake - delta * 3.5, 0.0)
var base := _rock_xf() if kind == "rock" else base_xf
var wobble := sin(_shake * 40.0) * 0.035 * _shake
_set_instance(Transform3D(Basis(_shake_axis, wobble) * base.basis, base.origin))
## Rocks shrink toward 55% of their size as they're mined.
func _rock_xf() -> Transform3D:
var k := 0.55 + 0.45 * clampf(hp / max_hp, 0.0, 1.0)
return Transform3D(base_xf.basis.scaled_local(Vector3.ONE * k), base_xf.origin)
func _set_instance(xf: Transform3D) -> void:
if mmi != null and index >= 0:
mmi.multimesh.set_instance_transform(index, xf)
## Restores saved damage when a world loads. hp <= 0 = already felled/mined
## (no animation, no drops).
func restore(saved_hp: float) -> void:
hp = saved_hp
if hp <= 0.0:
depleted = true
_deplete(global_position, false)
elif kind == "rock":
_set_instance(_rock_xf())
func _deplete(from: Vector3, animate: bool = true) -> void:
# Hide the batched instance (moved far below ground: a zero scale would
# give the wind shaders a non-invertible matrix).
_set_instance(Transform3D(base_xf.basis, base_xf.origin + Vector3(0, -1000, 0)))
collision_layer = 0
if kind == "tree" and mmi != null:
_fell(from, animate)
else:
if animate:
_burst(global_position + Vector3(0, 0.8, 0), chip_color, 40)
queue_free()
func _fell(from: Vector3, animate: bool) -> void:
var world := get_parent()
if animate:
_fall_animation(world, from)
_make_stump(world)
func _fall_animation(world: Node, from: Vector3) -> void:
var falling := MeshInstance3D.new()
falling.mesh = mmi.multimesh.mesh
world.add_child(falling)
falling.global_transform = base_xf
var away := base_xf.origin - from
away.y = 0.0
var axis := away.normalized().cross(Vector3.DOWN) if away.length() > 0.01 else Vector3.RIGHT
var start := base_xf.basis
var tw := falling.create_tween()
tw.tween_method(func(a: float): falling.global_basis = Basis(axis, a) * start, 0.0, deg_to_rad(86.0), 1.6) \
.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
tw.tween_callback(func(): _burst(falling.global_position + away.normalized() * 3.0, Color(0.3, 0.45, 0.2), 30))
tw.tween_interval(1.2)
tw.tween_property(falling, "position:y", falling.position.y - 4.0, 1.5)
tw.tween_callback(falling.queue_free)
func _make_stump(world: Node) -> void:
var stump := MeshInstance3D.new()
var s := base_xf.basis.get_scale().x
stump.mesh = ProcMesh.stump_mesh(rng.randi(), 0.45, 0.3 * s)
stump.material_override = stump_material
world.add_child(stump)
stump.global_position = base_xf.origin
# Keep collision as a short stump.
for c in get_children():
if c is CollisionShape3D and c.shape is CapsuleShape3D:
var cyl := CylinderShape3D.new()
cyl.radius = (c.shape as CapsuleShape3D).radius
cyl.height = 0.6
c.shape = cyl
c.position = Vector3(0, 0.3, 0)
collision_layer = 1
set_process(false)
remove_from_group("harvestable")
set_meta("is_stump", true)
static func _burst_material(color: Color) -> StandardMaterial3D:
var m := StandardMaterial3D.new()
m.albedo_color = color
m.roughness = 1.0
m.vertex_color_use_as_albedo = false
return m
func _burst(pos: Vector3, color: Color, amount: int) -> void:
var p := CPUParticles3D.new()
var box := BoxMesh.new()
box.size = Vector3.ONE * 0.06
box.material = _burst_material(color)
p.mesh = box
p.amount = amount
p.one_shot = true
p.explosiveness = 0.95
p.lifetime = 0.9
p.direction = Vector3.UP
p.spread = 70.0
p.initial_velocity_min = 2.0
p.initial_velocity_max = 4.5
p.gravity = Vector3(0, -12, 0)
p.scale_amount_min = 0.6
p.scale_amount_max = 1.4
p.angular_velocity_min = -400.0
p.angular_velocity_max = 400.0
get_parent().add_child(p)
p.global_position = pos
p.emitting = true
p.finished.connect(p.queue_free)