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: 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 := {} 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) ## 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)