Files
Cinderwild/scripts/world/resource_node.gd

275 lines
9.6 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
var _stump: MeshInstance3D
var _orig_shapes: Array = [] ## [CollisionShape3D, shape, position] before the stump
## Emitted when a player fells or mines this (not when a save restores it).
signal depleted_now(node: Node)
func display_name() -> String:
if is_iron:
return "Rusty boulder (iron ore)"
return "Tree" if kind == "tree" else "Boulder"
# --- rusty boulders (iron ore) ---------------------------------------------------
var is_iron := false
var ore_vis: Node3D ## the rust streaks, following the boulder's instance
static var _patch_mesh: ArrayMesh
## Turns this boulder into a rusty one: iron ore when it breaks, and red-brown
## streaks on its surface (`hull` = the rock mesh's points, unscaled).
func make_iron(hull: PackedVector3Array) -> void:
is_iron = true
drops_final = {"stone": [2, 4], "iron_ore": [2, 4]}
chip_color = Color(0.55, 0.3, 0.18)
if DisplayServer.get_name() == "headless":
return
if _patch_mesh == null:
var n := FastNoiseLite.new()
n.seed = 5
n.frequency = 2.0
var st := ProcMesh._begin()
ProcMesh._blob(st, Vector3.ZERO, Vector3(0.13, 0.1, 0.018), n, 0.25, Color(0.48, 0.24, 0.12), RandomNumberGenerator.new(), 0)
_patch_mesh = ProcMesh._commit(st)
ore_vis = Node3D.new()
ore_vis.top_level = true
add_child(ore_vis)
# Streaks on the upper surface, spread out.
var up: Array = []
for q: Vector3 in hull:
if q.y > 0.15:
up.append(q)
up.sort_custom(func(a, b): return a.x + a.z * 0.37 < b.x + b.z * 0.37)
var mat := ItemDB.item_material()
for i in range(mini(6, up.size())):
var q: Vector3 = up[int(float(i) / 6.0 * up.size())]
var mi := MeshInstance3D.new()
mi.mesh = _patch_mesh
mi.material_override = mat
mi.transform = Transform3D(Basis.looking_at(-q.normalized(), Vector3.UP if absf(q.normalized().y) < 0.95 else Vector3.RIGHT), q * 0.97)
ore_vis.add_child(mi)
ore_vis.global_transform = base_xf
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 := {}
for id in drops_per_hit:
out[id] = drops_per_hit[id]
if hp - damage <= 0.0:
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]))
_show_hit(hp - damage, point, from)
return out
## A hit someone else's game reported, via the server (world_net.gd): the
## same chips, shake and fall, with no drops.
func apply_remote(new_hp: float, point: Vector3, from: Vector3) -> void:
if not depleted:
_show_hit(new_hp, point, from)
func _show_hit(new_hp: float, point: Vector3, from: Vector3) -> void:
hp = new_hp
_burst(point, chip_color, 14)
if hp <= 0.0:
depleted = true
_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())
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)
if ore_vis != null:
ore_vis.global_transform = xf # (shrinks, shakes, hides and regrows with it)
## 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)
# Kept (hidden, no collision) so it can regrow later.
set_process(false)
remove_from_group("harvestable")
if animate:
depleted_now.emit(self)
## Brings a felled tree or mined boulder back, growing in over a few seconds.
func regrow() -> void:
if not depleted:
return
depleted = false
hp = max_hp
if _stump != null:
_stump.queue_free()
_stump = null
for e in _orig_shapes:
e[0].shape = e[1]
e[0].position = e[2]
_orig_shapes.clear()
if has_meta("is_stump"):
remove_meta("is_stump")
collision_layer = 1
add_to_group("harvestable")
set_process(true)
if is_inside_tree() and DisplayServer.get_name() != "headless":
var tw := create_tween()
tw.tween_method(func(k: float): _set_instance(Transform3D(base_xf.basis.scaled_local(Vector3.ONE * k), base_xf.origin)),
0.15, 1.0, 4.0).set_trans(Tween.TRANS_SINE)
else:
_set_instance(base_xf)
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
_stump = stump
# Keep collision as a short stump.
for c in get_children():
if c is CollisionShape3D and c.shape is CapsuleShape3D:
_orig_shapes.append([c, c.shape, c.position])
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)