275 lines
9.6 KiB
GDScript
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)
|