- Seeded weather (clear/cloudy/rain/storm, snow and blizzards on cold days and above the snowline), lightning, synthesised rain/wind/thunder sound - Rain/snow particles stopped by roofs; wet ground, settled snow and wind via global shader uniforms - Snap-together wood floor, wall, door, roof and gable with structural support, collapse and X to dismantle - Shelter detection: dry under a roof, warmer and Rested inside with a fire; rain burns an uncovered campfire faster - Bed sets the spawn point and lets you sleep through the night - Trees, boulders and ground pickups regrow; timers saved - Grass cleared under floors; shorter HUD hint - New test_weather_building suite (62 checks) and tests/run_all.sh, which also fails on script errors
221 lines
7.5 KiB
GDScript
221 lines
7.5 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:
|
|
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)
|
|
# 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)
|