Wolves with a den, fishing, and a mixed diet
Wolves - Den: a boulder cave at the foot of the rim, sited from the seed (away from the sea and spawn, mouth toward the valley, clearing in front) - Grace period: no wolves days 1-2; they move in on day 3 and howl; hunting from night 4; pack of 2, 3 from day 6, 4 from day 9; replaced by day - Wary pack hunters: circle and dart in one at a time; a lit campfire is a safe zone, a torch makes them hesitate, walls block bites (line of sight); hit one and the pack turns; hurt wolves flee; a lone wolf is timid; by day they defend the den; home at dawn - Glowing eyes, synthesised howls, can't sleep with wolves prowling - Wolf pelt and Wolf Cloak (+25 warmth) - Animals take a committed detour around obstacles Fishing - Fishing rod (workbench); cast a float onto the lake, 1.5 s to land a bite - Perch and rarer trout; faster bites at dawn/dusk and in rain; grill them - Visible fish near the shore that jump with splash rings Mixed diet - Foods belong to meat, fish or greens; each level runs down over ~1.5 days - One group low: no healing; two low: Malnourished (health drains); all three good: Balanced diet (heal x1.5); HUD diet bars; saved Tests: test_wolves and test_fishing_diet; run_all.sh covers 9 suites
This commit is contained in:
@@ -294,6 +294,37 @@ func butcher(player: Node3D, carcass: Node3D) -> bool:
|
||||
carcass.queue_free()
|
||||
return true
|
||||
|
||||
# --- fishing ----------------------------------------------------------------------
|
||||
|
||||
## Casts the held rod from `origin` along `dir`.
|
||||
func cast_line(player: Node3D, origin: Vector3, dir: Vector3, world: Node3D) -> bool:
|
||||
var rod: Dictionary = ItemDB.get_item(player.held_item_id()).get("fishing", {})
|
||||
if rod.is_empty() or player.global_position.distance_to(origin) > 2.5:
|
||||
return false
|
||||
if not player.survival.use_stamina(float(rod.get("stamina", 4))):
|
||||
notify.emit("Too tired")
|
||||
return false
|
||||
var d := dir.normalized()
|
||||
d.y = maxf(d.y, -0.2) + 0.35 # a lobbed cast, even when looking level
|
||||
world.start_fishing(player, origin, d.normalized() * float(rod.get("cast_speed", 12.0)))
|
||||
return true
|
||||
|
||||
## Reels in. With a fish biting you land it; otherwise the line just comes in.
|
||||
func reel(player: Node3D, world: Node3D) -> bool:
|
||||
var b: Node3D = world.bobber
|
||||
if b == null or not is_instance_valid(b):
|
||||
return false
|
||||
var caught := ""
|
||||
if b.state == "biting":
|
||||
caught = b.fish
|
||||
world.end_fishing()
|
||||
if caught.is_empty():
|
||||
return false
|
||||
if _give(player, caught, 1) == 0:
|
||||
world.drop_item(caught, 1, Transform3D(Basis(), player.global_position + Vector3(0, 0.1, 0)))
|
||||
notify.emit("Caught a %s" % ItemDB.item_name(caught).to_lower())
|
||||
return true
|
||||
|
||||
## Puts on the cloak in `slot` (swapping out any cloak you were wearing).
|
||||
func wear(player: Node3D, slot: int) -> bool:
|
||||
var s = player.inventory.get_slot(slot)
|
||||
|
||||
@@ -34,6 +34,11 @@ const KINDS := {
|
||||
"notice": 0.0, "notice_loud": 0.0, "calm": 30.0,
|
||||
"damage": 14.0, "attack_range": 1.7, "attack_cd": 1.6, "aggro": 18.0,
|
||||
"drops": {"raw_meat": [3, 4], "hide": [1, 2]}},
|
||||
# Wolves are driven by wolf.gd + wolf_pack.gd; "notice" is unused.
|
||||
"wolf": {"name": "Wolf", "hp": 45.0, "walk": 1.6, "run": 8.6, "turn": 6.0,
|
||||
"notice": 0.0, "notice_loud": 0.0, "calm": 40.0,
|
||||
"damage": 9.0, "attack_range": 1.8, "attack_cd": 1.2,
|
||||
"drops": {"raw_meat": [1, 2], "wolf_pelt": [1, 1]}},
|
||||
}
|
||||
const CARCASS_SECONDS := 300.0
|
||||
|
||||
@@ -60,6 +65,13 @@ var _phase := 0.0
|
||||
var _visual: Node3D
|
||||
var _legs: Array[Node3D] = []
|
||||
var _flash := 0.0
|
||||
var _detour_t := 0.0 ## > 0: following a detour round an obstacle
|
||||
var _detour_side := 1.0
|
||||
|
||||
## Sets the heading, unless the animal is in the middle of a detour.
|
||||
func steer(y: float) -> void:
|
||||
if _detour_t <= 0.0:
|
||||
target_yaw = y
|
||||
var _mat: StandardMaterial3D
|
||||
var _hit_mat: StandardMaterial3D
|
||||
|
||||
@@ -88,6 +100,18 @@ func setup(k: String, w: Node3D, pos: Vector3, seed: int) -> void:
|
||||
body.mesh = parts.body
|
||||
body.material_override = _mat
|
||||
_visual.add_child(body)
|
||||
if parts.has("eyes"):
|
||||
# Eyes that catch the light: you see a wolf's eyes in the dark first.
|
||||
var eyes := MeshInstance3D.new()
|
||||
eyes.mesh = parts.eyes
|
||||
var em := StandardMaterial3D.new()
|
||||
em.albedo_color = Color(1.0, 0.85, 0.4)
|
||||
em.emission_enabled = true
|
||||
em.emission = Color(1.0, 0.75, 0.3)
|
||||
em.emission_energy_multiplier = 2.5
|
||||
eyes.material_override = em
|
||||
eyes.set_meta("keep_mat", true)
|
||||
_visual.add_child(eyes)
|
||||
for h: Vector3 in parts.hips:
|
||||
var pivot := Node3D.new()
|
||||
pivot.position = h
|
||||
@@ -105,7 +129,7 @@ func setup(k: String, w: Node3D, pos: Vector3, seed: int) -> void:
|
||||
cs.position = Vector3(0, parts.height * 0.5, 0)
|
||||
if k != "rabbit":
|
||||
# Long bodies: lay the capsule along the spine.
|
||||
cap.height = 1.5 if k == "deer" else 1.4
|
||||
cap.height = {"deer": 1.5, "boar": 1.4, "wolf": 1.25}.get(k, 1.4)
|
||||
cs.rotation.x = PI * 0.5
|
||||
cs.position = Vector3(0, parts.height * 0.55, 0)
|
||||
add_child(cs)
|
||||
@@ -219,12 +243,19 @@ func _move(dt: float) -> void:
|
||||
yaw = rotate_toward(yaw, target_yaw, def.turn * dt)
|
||||
if speed < 0.01:
|
||||
return
|
||||
_detour_t = maxf(_detour_t - dt, 0.0)
|
||||
var dir := Vector3(-sin(yaw), 0, -cos(yaw))
|
||||
if _blocked(dir):
|
||||
# Turn away; fleeing animals turn harder.
|
||||
var turn := rng.randf_range(0.8, 2.2) * (1.0 if rng.randf() < 0.5 else -1.0)
|
||||
# Turn away and keep to the new heading for a moment (a detour), so
|
||||
# an animal heading somewhere slides round an obstacle instead of
|
||||
# turning straight back into it. Keep turning the same way while
|
||||
# still blocked.
|
||||
if _detour_t <= 0.0:
|
||||
_detour_side = 1.0 if rng.randf() < 0.5 else -1.0
|
||||
var turn := rng.randf_range(0.8, 1.6) * _detour_side
|
||||
target_yaw = yaw + turn
|
||||
yaw += turn * 0.35
|
||||
_detour_t = rng.randf_range(1.0, 1.8)
|
||||
speed *= 0.5
|
||||
if state == "charge":
|
||||
return
|
||||
@@ -253,6 +284,13 @@ func _blocked(dir: Vector3) -> bool:
|
||||
return not get_world_3d().direct_space_state.intersect_ray(q).is_empty()
|
||||
|
||||
func _animate(dt: float) -> void:
|
||||
if state == "rest":
|
||||
# Lying down: body lowered, legs folded under.
|
||||
_visual.position.y = lerpf(_visual.position.y, -0.3, minf(dt * 3.0, 1.0))
|
||||
_visual.rotation.x = lerpf(_visual.rotation.x, 0.0, minf(dt * 4.0, 1.0))
|
||||
for l in _legs:
|
||||
l.rotation.x = lerpf(l.rotation.x, 1.35 * (1.0 if l.position.z < 0.0 else -1.0), minf(dt * 3.0, 1.0))
|
||||
return
|
||||
var k := speed / maxf(def.run, 0.01)
|
||||
_phase += dt * (3.0 + speed * 2.2)
|
||||
var amp := clampf(speed / 2.0, 0.0, 1.0) * 0.6
|
||||
@@ -288,6 +326,10 @@ func take_hit(damage: float, from: Vector3, embed_id: String = "") -> void:
|
||||
if hp <= 0.0:
|
||||
_die(from)
|
||||
return
|
||||
_react(from)
|
||||
|
||||
## How a living animal responds to being hit (wolves override this).
|
||||
func _react(from: Vector3) -> void:
|
||||
if kind == "boar" and hp > def.hp * 0.3:
|
||||
state = "charge"
|
||||
_aggro = def.aggro
|
||||
@@ -328,10 +370,10 @@ func butcher_drops() -> Dictionary:
|
||||
|
||||
func _set_material(m: Material) -> void:
|
||||
for c in _visual.get_children():
|
||||
if c is MeshInstance3D:
|
||||
if c is MeshInstance3D and not c.has_meta("keep_mat"):
|
||||
c.material_override = m
|
||||
for g in c.get_children():
|
||||
if g is MeshInstance3D:
|
||||
if g is MeshInstance3D and not g.has_meta("keep_mat"):
|
||||
g.material_override = m
|
||||
|
||||
func _burst() -> void:
|
||||
|
||||
@@ -17,6 +17,7 @@ static func build(kind: String) -> Dictionary:
|
||||
match kind:
|
||||
"rabbit": d = _rabbit()
|
||||
"deer": d = _deer()
|
||||
"wolf": d = _wolf()
|
||||
_: d = _boar()
|
||||
_cache[kind] = d
|
||||
return d
|
||||
@@ -96,3 +97,29 @@ static func _boar() -> Dictionary:
|
||||
return {"body": P._commit(st), "leg": _leg(0.36, 0.07, 0.05, bristle, Color(0.15, 0.12, 0.1), 222),
|
||||
"hips": [Vector3(-0.17, 0.38, -0.36), Vector3(0.17, 0.38, -0.36), Vector3(-0.17, 0.4, 0.38), Vector3(0.17, 0.4, 0.38)],
|
||||
"radius": 0.42, "height": 0.95}
|
||||
|
||||
## Grey wolf: lean body, long snout, pointed ears, bushy tail. "eyes" is a
|
||||
## separate mesh so it can glow (creature.gd gives it an emissive material).
|
||||
static func _wolf() -> Dictionary:
|
||||
var coat := Color(0.42, 0.42, 0.44)
|
||||
var pale := Color(0.72, 0.71, 0.68)
|
||||
var dark := Color(0.16, 0.15, 0.15)
|
||||
var st := P._begin()
|
||||
var r := _rng(231)
|
||||
var n := _noise(231, 1.5)
|
||||
P._blob(st, Vector3(0, 0.7, 0.05), Vector3(0.22, 0.24, 0.55), n, 0.15, coat, r, 1)
|
||||
P._blob(st, Vector3(0, 0.8, -0.32), Vector3(0.24, 0.26, 0.3), n, 0.25, P._mul(coat, 1.08), r, 1) # ruff
|
||||
P._blob(st, Vector3(0, 0.6, -0.1), Vector3(0.16, 0.12, 0.35), n, 0.1, pale, r, 1) # belly
|
||||
P._blob(st, Vector3(0, 0.92, -0.62), Vector3(0.15, 0.14, 0.17), n, 0.12, coat, r, 1) # head
|
||||
P._cylinder(st, Vector3(0, 0.88, -0.72), Vector3(0, 0.83, -0.95), 0.09, 0.05, 6, pale, r, -1.0, dark) # snout
|
||||
for s in [-1.0, 1.0]:
|
||||
P._cylinder(st, Vector3(s * 0.08, 1.02, -0.6), Vector3(s * 0.1, 1.2, -0.58), 0.05, 0.008, 4, P._mul(coat, 0.85), r)
|
||||
# Bushy tail, hanging down and back.
|
||||
P._cylinder(st, Vector3(0, 0.78, 0.55), Vector3(0, 0.5, 0.85), 0.07, 0.05, 5, coat, r)
|
||||
P._blob(st, Vector3(0, 0.45, 0.9), Vector3(0.06, 0.08, 0.06), n, 0.2, pale, r, 0)
|
||||
var est := P._begin()
|
||||
for s in [-1.0, 1.0]:
|
||||
P._blob(est, Vector3(s * 0.065, 0.97, -0.75), Vector3.ONE * 0.022, n, 0.0, Color(1.0, 0.8, 0.3), r, 0)
|
||||
return {"body": P._commit(st), "eyes": P._commit(est), "leg": _leg(0.52, 0.045, 0.03, coat, dark, 232),
|
||||
"hips": [Vector3(-0.11, 0.52, -0.38), Vector3(0.11, 0.52, -0.38), Vector3(-0.11, 0.54, 0.38), Vector3(0.11, 0.54, 0.38)],
|
||||
"radius": 0.32, "height": 1.0}
|
||||
|
||||
174
scripts/creatures/den.gd
Normal file
174
scripts/creatures/den.gd
Normal file
@@ -0,0 +1,174 @@
|
||||
extends Node3D
|
||||
## The wolf den: a cave mouth of piled boulders at the foot of the mountain
|
||||
## rim, opening toward the valley. Built from the same procedural rocks as the
|
||||
## scenery, so it's part of the island and regenerates from the seed.
|
||||
##
|
||||
## The site is chosen deterministically: a flat, clear spot close to the rim,
|
||||
## away from the sea and the spawn. The rocks' colliders are also on the
|
||||
## building layer, so standing inside the cave counts as being under a roof
|
||||
## (it's dry in there, if you dare).
|
||||
|
||||
const ProcMesh = preload("res://scripts/world/proc_mesh.gd")
|
||||
const BONE := Color(0.88, 0.85, 0.76)
|
||||
|
||||
## [local position, radii] of each boulder. The interior is about 3.6 m
|
||||
## wide, 4 m deep and 2 m high; +Z is the mouth.
|
||||
const ROCKS := [
|
||||
[Vector3(0, 1.2, -3.4), Vector3(3.2, 2.4, 1.6)], # back wall
|
||||
[Vector3(-2.6, 1.1, -1.4), Vector3(1.4, 2.2, 2.4)], # left wall
|
||||
[Vector3(-2.4, 0.9, 0.9), Vector3(1.3, 1.8, 1.6)],
|
||||
[Vector3(2.6, 1.2, -1.5), Vector3(1.5, 2.3, 2.4)], # right wall
|
||||
[Vector3(2.3, 0.8, 0.9), Vector3(1.2, 1.6, 1.5)],
|
||||
[Vector3(0, 3.0, -1.2), Vector3(3.4, 0.9, 3.0)], # roof slab
|
||||
[Vector3(0, 2.0, -5.6), Vector3(4.5, 3.4, 3.0)], # mound behind
|
||||
[Vector3(-3.6, 1.6, -4.4), Vector3(2.5, 2.6, 2.2)],
|
||||
[Vector3(3.7, 1.8, -4.3), Vector3(2.6, 2.8, 2.3)],
|
||||
[Vector3(-2.5, 0.5, 2.6), Vector3(1.0, 0.8, 0.9)], # boulders at the mouth
|
||||
[Vector3(2.7, 0.45, 2.4), Vector3(0.9, 0.7, 0.8)],
|
||||
]
|
||||
## Where the wolves lie (local), facing out of the mouth.
|
||||
const SLOTS := [Vector3(-0.9, 0, -0.9), Vector3(1.0, 0, -1.3), Vector3(0.0, 0, -2.0), Vector3(-0.2, 0, 0.4)]
|
||||
const HALF_SIZE := 6.5 ## clear area needed around the site
|
||||
|
||||
var terrain: Node3D
|
||||
var yaw := 0.0 ## node rotation; local +Z points into the valley
|
||||
var found := false
|
||||
|
||||
## Finds the site and builds the den. Returns false (and builds nothing) if
|
||||
## no spot qualifies on this seed.
|
||||
func place(world: Node3D, rock_mat: Material) -> bool:
|
||||
terrain = world.terrain
|
||||
var site := _find_site(world)
|
||||
if site == Vector3.INF:
|
||||
push_warning("[den] no site found for the wolf den on this seed")
|
||||
return false
|
||||
found = true
|
||||
position = site
|
||||
yaw = atan2(-site.x, -site.z) # mouth toward the centre of the valley
|
||||
rotation.y = yaw
|
||||
_clear_area(world.props)
|
||||
_build(rock_mat)
|
||||
return true
|
||||
|
||||
func _find_site(world: Node3D) -> Vector3:
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = terrain.noise_seed + 777
|
||||
var start := rng.randf() * 360.0
|
||||
var space := world.get_world_3d().direct_space_state
|
||||
var box := BoxShape3D.new()
|
||||
box.size = Vector3(HALF_SIZE * 2.0, 6.0, HALF_SIZE * 2.0)
|
||||
var ex: Array[RID] = [terrain.get_node("TerrainBody").get_rid(), world.player.get_rid()]
|
||||
for step in range(52):
|
||||
var ang := deg_to_rad(start + step * 7.0)
|
||||
var dir := Vector2(cos(ang), sin(ang))
|
||||
# Walk inward from the rim until the den sits inside the walkable
|
||||
# area, a little way short of the wall.
|
||||
var r: float = terrain.play_radius + 10.0
|
||||
while r > terrain.play_radius - 40.0:
|
||||
var x := dir.x * r
|
||||
var z := dir.y * r
|
||||
if terrain.inside_boundary(x, z, 11.0):
|
||||
break
|
||||
r -= 1.0
|
||||
var c := Vector3(dir.x * r, 0, dir.y * r)
|
||||
if terrain.sea_weight(c.x, c.z) > 0.02 or Vector2(c.x, c.z).length() < 50.0:
|
||||
continue
|
||||
var hs: Array[float] = []
|
||||
for d in [Vector2(-1, -1), Vector2(1, -1), Vector2(-1, 1), Vector2(1, 1), Vector2.ZERO]:
|
||||
hs.append(terrain.sample_height(c.x + d.x * 4.0, c.z + d.y * 4.0))
|
||||
if hs.max() - hs.min() > 2.2 or hs.min() < terrain.water_level + 1.0:
|
||||
continue
|
||||
c.y = hs[4]
|
||||
# Trees and boulders in the way are cleared (_clear_area); just avoid
|
||||
# the thickest woods. Counted by position, because their colliders
|
||||
# were only just created and the physics space hasn't seen them yet.
|
||||
var crowd := 0
|
||||
for uid in world.props.resource_nodes:
|
||||
var n: Node3D = world.props.resource_nodes[uid]
|
||||
if Vector2(n.global_position.x - c.x, n.global_position.z - c.z).length() < HALF_SIZE:
|
||||
crowd += 1
|
||||
if crowd <= 5:
|
||||
return c
|
||||
return Vector3.INF
|
||||
|
||||
## Removes the trees, boulders, bushes and pickups the den is built over.
|
||||
## Deterministic (the site comes from the seed), and the other scenery keeps
|
||||
## its save ids because those come from creation order.
|
||||
func _clear_area(props: Node3D) -> void:
|
||||
var r := HALF_SIZE
|
||||
# Also a clearing in front of the mouth, so you can see the cave from the valley.
|
||||
var b := Basis(Vector3.UP, yaw)
|
||||
var front: Vector3 = position + b * Vector3(0, 0, 9.0)
|
||||
for dict in [props.resource_nodes, props.pickups, props.forage]:
|
||||
for uid in dict.keys():
|
||||
var n: Node3D = dict[uid]
|
||||
if not is_instance_valid(n):
|
||||
continue
|
||||
var p2 := Vector2(n.global_position.x, n.global_position.z)
|
||||
if p2.distance_to(Vector2(position.x, position.z)) < r or p2.distance_to(Vector2(front.x, front.z)) < 6.0:
|
||||
if n.has_method("_set_instance"):
|
||||
n._set_instance(Transform3D(Basis(), n.global_position + Vector3(0, -1000, 0)))
|
||||
dict.erase(uid)
|
||||
n.queue_free()
|
||||
|
||||
func _build(rock_mat: Material) -> void:
|
||||
var body := StaticBody3D.new()
|
||||
body.name = "Rocks"
|
||||
body.collision_layer = 1 | 4 # solid, and a roof for shelter checks
|
||||
add_child(body)
|
||||
var b := Basis(Vector3.UP, yaw)
|
||||
for i in range(ROCKS.size()):
|
||||
var lp: Vector3 = ROCKS[i][0]
|
||||
var radii: Vector3 = ROCKS[i][1]
|
||||
# Sit each boulder on the ground under it (the site isn't perfectly flat).
|
||||
var w: Vector3 = position + b * Vector3(lp.x, 0, lp.z)
|
||||
var y: float = terrain.sample_height(w.x, w.z) - position.y + lp.y - 0.25
|
||||
var mesh := ProcMesh.rock(terrain.noise_seed + 3100 + i, false)
|
||||
var mi := MeshInstance3D.new()
|
||||
mi.mesh = mesh
|
||||
mi.material_override = rock_mat
|
||||
var turn := Basis(Vector3.UP, float(i) * 1.7)
|
||||
mi.transform = Transform3D(turn.scaled(radii), Vector3(lp.x, y, lp.z))
|
||||
add_child(mi)
|
||||
var hull := mesh.create_convex_shape(true, true) as ConvexPolygonShape3D
|
||||
var pts := PackedVector3Array()
|
||||
for p: Vector3 in hull.points:
|
||||
pts.append(turn * (p * radii))
|
||||
hull.points = pts
|
||||
var cs := CollisionShape3D.new()
|
||||
cs.shape = hull
|
||||
cs.position = Vector3(lp.x, y, lp.z)
|
||||
body.add_child(cs)
|
||||
# Old bones on the cave floor.
|
||||
var st := ProcMesh._begin()
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = terrain.noise_seed + 778
|
||||
var n := FastNoiseLite.new()
|
||||
n.seed = 778
|
||||
for k in range(7):
|
||||
var c := Vector3(rng.randf_range(-1.4, 1.4), 0.0, rng.randf_range(-2.4, 1.2))
|
||||
var a := rng.randf() * TAU
|
||||
var dir := Vector3(cos(a), 0, sin(a)) * rng.randf_range(0.15, 0.3)
|
||||
ProcMesh._cylinder(st, c - dir + Vector3(0, 0.04, 0), c + dir + Vector3(0, 0.04, 0), 0.025, 0.02, 5, BONE, rng, -1.0, BONE)
|
||||
ProcMesh._blob(st, c + dir + Vector3(0, 0.05, 0), Vector3.ONE * 0.045, n, 0.2, BONE, rng, 0)
|
||||
var bones := MeshInstance3D.new()
|
||||
bones.mesh = ProcMesh._commit(st)
|
||||
bones.material_override = ItemDB.item_material()
|
||||
add_child(bones) # (the den's origin is the cave floor)
|
||||
|
||||
## World position of resting slot `i`, on the ground.
|
||||
func slot_pos(i: int) -> Vector3:
|
||||
var lp: Vector3 = SLOTS[i % SLOTS.size()]
|
||||
var w: Vector3 = position + Basis(Vector3.UP, yaw) * lp
|
||||
w.y = terrain.sample_height(w.x, w.z)
|
||||
return w
|
||||
|
||||
## Yaw a wolf uses to face out of the mouth (forward is -Z).
|
||||
func facing_out_yaw() -> float:
|
||||
var out := Basis(Vector3.UP, yaw) * Vector3(0, 0, 1)
|
||||
return atan2(-out.x, -out.z)
|
||||
|
||||
func mouth_pos() -> Vector3:
|
||||
var w: Vector3 = position + Basis(Vector3.UP, yaw) * Vector3(0, 0, 3.5)
|
||||
w.y = terrain.sample_height(w.x, w.z)
|
||||
return w
|
||||
163
scripts/creatures/wolf.gd
Normal file
163
scripts/creatures/wolf.gd
Normal file
@@ -0,0 +1,163 @@
|
||||
extends "res://scripts/creatures/creature.gd"
|
||||
## A wolf: a member of the pack that lives in the den (wolf_pack.gd decides
|
||||
## what the pack is doing; this decides what one wolf does about it).
|
||||
##
|
||||
## Wary pack hunters rather than berserkers:
|
||||
## - In the den by day they rest, but they defend it. Come within 14 m and
|
||||
## they get up and watch you; within 8 m (or hit one) and they attack.
|
||||
## - At night the pack stalks you. They circle at a distance and take turns
|
||||
## darting in to bite, one wolf at a time.
|
||||
## - Fire keeps them back: near a lit campfire they circle wide and never
|
||||
## close in. A held torch makes them hesitate.
|
||||
## - They can't bite through walls (a line-of-sight check), so a closed hut
|
||||
## is safe.
|
||||
## - A wolf on its own is timid: it keeps its distance and backs off.
|
||||
## - A badly hurt wolf runs, and comes back another night.
|
||||
|
||||
var pack: Node
|
||||
var slot := 0
|
||||
var wounded := false ## ran off hurt: stays away until dawn
|
||||
|
||||
func display_name() -> String:
|
||||
return "Wolf"
|
||||
|
||||
func interact_prompt(p: Node) -> String:
|
||||
if is_dead():
|
||||
return "Wolf carcass [E] Butcher"
|
||||
var s := "Wolf"
|
||||
match state:
|
||||
"rest": s = "Wolf (resting)"
|
||||
"alert", "stalk": s = "Wolf (watching you)"
|
||||
"lunge", "defend": s = "Wolf (attacking!)"
|
||||
if not Actions.melee_stats(p).armed and not ItemDB.get_item(p.held_item_id()).has("bow"):
|
||||
s += " (you need a weapon)"
|
||||
return s
|
||||
|
||||
func _think(dt: float) -> void:
|
||||
var pp: Vector3 = player.global_position
|
||||
var d := global_position.distance_to(pp)
|
||||
_state_t -= dt
|
||||
if state == "flee":
|
||||
var away := global_position - _threat
|
||||
steer(atan2(-away.x, -away.z))
|
||||
if _state_t <= 0.0 and d > 30.0:
|
||||
state = "home"
|
||||
return
|
||||
if wounded and pack.mode == "hunt":
|
||||
_go_to(pack.home_slot(self))
|
||||
state = "home"
|
||||
return
|
||||
match pack.mode:
|
||||
"den":
|
||||
_think_den(pp, d)
|
||||
"hunt":
|
||||
if _aggro > 0.0 and d < 25.0:
|
||||
_attack(pp, d, "defend")
|
||||
elif pack.alive_count() <= 1:
|
||||
_think_alone(pp, d)
|
||||
else:
|
||||
_think_hunt(pp, d)
|
||||
_:
|
||||
_think_den(pp, d)
|
||||
|
||||
## Daytime (and the howling night before hunts begin): rest at the den,
|
||||
## defend it if you come too close.
|
||||
func _think_den(pp: Vector3, d: float) -> void:
|
||||
var den_d: float = pack.den_pos.distance_to(pp)
|
||||
if (_aggro > 0.0 or den_d < pack.DEFEND_RADIUS) and d < 25.0:
|
||||
_attack(pp, d, "defend")
|
||||
return
|
||||
if den_d < pack.ALERT_RADIUS:
|
||||
state = "alert"
|
||||
var to := pp - global_position
|
||||
target_yaw = atan2(-to.x, -to.z)
|
||||
return
|
||||
var home: Vector3 = pack.home_slot(self)
|
||||
if Vector2(home.x - global_position.x, home.z - global_position.z).length() > 1.2:
|
||||
state = "home"
|
||||
_go_to(home)
|
||||
else:
|
||||
state = "rest"
|
||||
target_yaw = pack.den_yaw + (slot - 1.5) * 0.6
|
||||
|
||||
## Night, with the pack: circle, and dart in when it's your turn.
|
||||
func _think_hunt(pp: Vector3, d: float) -> void:
|
||||
match state:
|
||||
"lunge":
|
||||
# Break off first if you've reached a fire or it's taking too long.
|
||||
if _state_t <= 0.0 or not pack.may_close_in():
|
||||
state = "stalk"
|
||||
pack.lunge_done(self)
|
||||
else:
|
||||
_attack(pp, d, "lunge")
|
||||
"retreat":
|
||||
var away := global_position - pp
|
||||
steer(atan2(-away.x, -away.z))
|
||||
if _state_t <= 0.0:
|
||||
state = "stalk"
|
||||
_:
|
||||
state = "stalk"
|
||||
_go_to(pack.circle_point(self))
|
||||
if pack.request_lunge(self):
|
||||
state = "lunge"
|
||||
_state_t = 4.0
|
||||
|
||||
## The last wolf of a pack keeps its distance and won't start a fight.
|
||||
func _think_alone(pp: Vector3, d: float) -> void:
|
||||
if d < 10.0:
|
||||
_threat = pp
|
||||
state = "flee"
|
||||
_state_t = 3.0
|
||||
return
|
||||
state = "stalk"
|
||||
_go_to(pp + (global_position - pp).normalized() * 18.0)
|
||||
|
||||
## Run at the player and bite when in range with nothing in between.
|
||||
func _attack(pp: Vector3, d: float, as_state: String) -> void:
|
||||
state = as_state
|
||||
var to := pp - global_position
|
||||
steer(atan2(-to.x, -to.z))
|
||||
if d < def.attack_range and _attack_cd <= 0.0 and _can_reach_player():
|
||||
_attack_cd = def.attack_cd
|
||||
player.survival.damage(def.damage, global_position)
|
||||
if as_state == "lunge":
|
||||
state = "retreat"
|
||||
_state_t = 1.6
|
||||
pack.lunge_done(self)
|
||||
|
||||
## No wall, door or rock between the wolf's jaws and the player.
|
||||
func _can_reach_player() -> bool:
|
||||
var from := global_position + Vector3(0, 0.8, 0)
|
||||
var to: Vector3 = player.global_position + Vector3(0, 1.0, 0)
|
||||
var q := PhysicsRayQueryParameters3D.create(from, to, 1)
|
||||
q.exclude = [get_rid(), player.get_rid(), terrain.get_node("TerrainBody").get_rid()]
|
||||
return get_world_3d().direct_space_state.intersect_ray(q).is_empty()
|
||||
|
||||
func _go_to(p: Vector3) -> void:
|
||||
var to := p - global_position
|
||||
to.y = 0.0
|
||||
if to.length() > 0.3:
|
||||
steer(atan2(-to.x, -to.z))
|
||||
_goal_d = to.length()
|
||||
|
||||
var _goal_d := 0.0
|
||||
|
||||
func _desired_speed() -> float:
|
||||
match state:
|
||||
"rest", "alert": return 0.0
|
||||
"home": return 2.2 if _goal_d < 8.0 else 6.0
|
||||
"stalk": return 0.0 if _goal_d < 1.0 else (3.4 if _goal_d < 7.0 else 7.5)
|
||||
"lunge", "defend": return def.run
|
||||
"retreat": return 6.0
|
||||
"flee": return def.run
|
||||
return 0.0
|
||||
|
||||
func _react(from: Vector3) -> void:
|
||||
pack.alert(from)
|
||||
if hp < def.hp * 0.4:
|
||||
wounded = true
|
||||
_threat = from
|
||||
state = "flee"
|
||||
_state_t = 6.0
|
||||
else:
|
||||
_aggro = 20.0
|
||||
267
scripts/creatures/wolf_pack.gd
Normal file
267
scripts/creatures/wolf_pack.gd
Normal file
@@ -0,0 +1,267 @@
|
||||
extends Node3D
|
||||
## The wolf pack: when wolves exist, what they're doing, and who attacks.
|
||||
##
|
||||
## Schedule (a grace period so you can build first):
|
||||
## - Days 1-2: the den is empty (old bones only).
|
||||
## - Day 3: wolves move in. They rest in the den by day and howl at night,
|
||||
## but don't hunt yet. A toast warns you.
|
||||
## - Night 4 onward: the pack hunts. It's 2 wolves to start, 3 from day 6,
|
||||
## 4 from day 9. At dawn they go back to the den. Wolves you killed are
|
||||
## replaced at the den during the day, when you aren't watching.
|
||||
##
|
||||
## Night is 19:30 to 05:30, the same window the bed allows sleeping in. The
|
||||
## small hours belong to the previous day's night.
|
||||
##
|
||||
## While hunting the pack circles you and lets one wolf at a time dart in
|
||||
## (every few seconds, slower if you hold a torch). Near a lit campfire it
|
||||
## circles wide around the fire and never closes in.
|
||||
|
||||
const WolfScript = preload("res://scripts/creatures/wolf.gd")
|
||||
|
||||
const DEN_DAY := 3 ## wolves arrive in the den
|
||||
const HUNT_DAY := 4 ## first night they hunt
|
||||
const ALERT_RADIUS := 14.0 ## by day: closer than this to the den and they watch you
|
||||
const DEFEND_RADIUS := 8.0 ## ...and closer than this they attack
|
||||
const FIRE_SAFE := 8.0 ## within this of a lit campfire, wolves won't close in
|
||||
const RING := 10.0 ## circling radius around you
|
||||
const FIRE_RING := 14.0 ## circling radius around your fire
|
||||
const ATTACK_GAP := 4.5 ## seconds between darts (x2.5 with a torch)
|
||||
|
||||
var world: Node3D
|
||||
var den: Node3D
|
||||
var den_pos := Vector3.ZERO
|
||||
var den_yaw := 0.0
|
||||
var mode := "none" ## none | den | hunt
|
||||
var members: Array = []
|
||||
var enabled := true
|
||||
var _rng := RandomNumberGenerator.new()
|
||||
var _lunger: Node = null
|
||||
var _gap := 2.0
|
||||
var _circle_a := 0.0
|
||||
var _warned := {} ## day -> true once that night's toast was shown
|
||||
var _howl_left := 0.0
|
||||
var _audio: AudioStreamPlayer3D
|
||||
static var _howl: AudioStreamWAV
|
||||
|
||||
func setup(w: Node3D, d: Node3D) -> void:
|
||||
world = w
|
||||
den = d
|
||||
den_pos = d.position
|
||||
den_yaw = d.facing_out_yaw()
|
||||
_rng.seed = w.terrain.noise_seed + 779
|
||||
if DisplayServer.get_name() != "headless":
|
||||
_audio = AudioStreamPlayer3D.new()
|
||||
_audio.stream = howl_stream()
|
||||
_audio.unit_size = 30.0
|
||||
_audio.max_distance = 400.0
|
||||
_audio.volume_db = 2.0
|
||||
add_child(_audio)
|
||||
|
||||
## The "day" a moment belongs to: the hours after midnight count as the
|
||||
## previous day's night.
|
||||
func effective_day() -> int:
|
||||
var h: float = world.day_night.hours()
|
||||
return world.day_night.day - (1 if h < 5.5 else 0)
|
||||
|
||||
func is_night() -> bool:
|
||||
var h: float = world.day_night.hours()
|
||||
return h >= 19.5 or h < 5.5
|
||||
|
||||
static func pack_size(day: int) -> int:
|
||||
if day < DEN_DAY:
|
||||
return 0
|
||||
if day < 6:
|
||||
return 2
|
||||
if day < 9:
|
||||
return 3
|
||||
return 4
|
||||
|
||||
func alive() -> Array:
|
||||
var out := []
|
||||
for m in members:
|
||||
if is_instance_valid(m) and not m.is_dead():
|
||||
out.append(m)
|
||||
return out
|
||||
|
||||
func alive_count() -> int:
|
||||
return alive().size()
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
if enabled:
|
||||
step(delta)
|
||||
|
||||
## Pack-level logic (the wolves run their own step()).
|
||||
func step(dt: float) -> void:
|
||||
var day := effective_day()
|
||||
var night := is_night()
|
||||
var want := "none"
|
||||
if day >= DEN_DAY:
|
||||
want = "hunt" if night and day >= HUNT_DAY else "den"
|
||||
if want != mode:
|
||||
_enter(want, day)
|
||||
if mode == "none":
|
||||
return
|
||||
if night:
|
||||
_warn(day)
|
||||
# Top up the den by day while you're not looking.
|
||||
if not night and alive_count() < pack_size(day):
|
||||
fill_den(false)
|
||||
_gap = maxf(_gap - dt, 0.0)
|
||||
_circle_a += dt * 0.25
|
||||
if _lunger != null and (not is_instance_valid(_lunger) or _lunger.is_dead() or _lunger.state != "lunge"):
|
||||
_lunger = null
|
||||
# At dawn wolves head home; once one is well away from you (out of sight),
|
||||
# it slips straight back rather than picking its way across the island.
|
||||
if mode == "den":
|
||||
var pp: Vector3 = world.player.global_position
|
||||
for m in alive():
|
||||
if m.global_position.distance_to(den_pos) > 6.0 and m.global_position.distance_to(pp) > 45.0 \
|
||||
and pp.distance_to(den_pos) > 35.0:
|
||||
m.global_position = den.slot_pos(m.slot)
|
||||
m.wounded = false
|
||||
m.state = "rest"
|
||||
if night and day >= DEN_DAY:
|
||||
_howl_left -= dt
|
||||
if _howl_left <= 0.0:
|
||||
_howl_left = _rng.randf_range(50.0, 110.0)
|
||||
howl()
|
||||
|
||||
func _enter(m: String, day: int) -> void:
|
||||
mode = m
|
||||
if m == "none":
|
||||
for w in members:
|
||||
if is_instance_valid(w):
|
||||
w.queue_free()
|
||||
members.clear()
|
||||
return
|
||||
if m == "den":
|
||||
for w in alive():
|
||||
w.wounded = false
|
||||
if members.is_empty():
|
||||
fill_den(true)
|
||||
|
||||
## Once per night: a toast on the first howling night and the first hunt.
|
||||
func _warn(day: int) -> void:
|
||||
if _warned.has(day):
|
||||
return
|
||||
_warned[day] = true
|
||||
if day == DEN_DAY:
|
||||
Actions.notify.emit("Wolves are howling up in the hills. They'll come hunting soon.")
|
||||
elif day == HUNT_DAY:
|
||||
Actions.notify.emit("The wolves are hunting tonight. Stay near a fire or behind closed walls.")
|
||||
_howl_left = _rng.randf_range(2.0, 6.0)
|
||||
|
||||
## Brings the pack up to size, wolves lying in the den. `force` ignores
|
||||
## whether you're watching.
|
||||
func fill_den(force: bool) -> void:
|
||||
var need: int = pack_size(effective_day()) - alive_count()
|
||||
if need <= 0:
|
||||
return
|
||||
if not force and world.player.global_position.distance_to(den_pos) < 35.0:
|
||||
return
|
||||
members = alive()
|
||||
var used := {}
|
||||
for m in members:
|
||||
used[m.slot] = true
|
||||
var i := 0
|
||||
while need > 0 and i < 4:
|
||||
if not used.has(i):
|
||||
var w: AnimatableBody3D = WolfScript.new()
|
||||
w.setup("wolf", world, den.slot_pos(i), _rng.randi())
|
||||
w.pack = self
|
||||
w.slot = i
|
||||
w.state = "rest"
|
||||
w.yaw = den_yaw + (i - 1.5) * 0.6
|
||||
w.rotation.y = w.yaw
|
||||
add_child(w)
|
||||
members.append(w)
|
||||
need -= 1
|
||||
i += 1
|
||||
|
||||
func home_slot(w: Node) -> Vector3:
|
||||
return den.slot_pos(w.slot)
|
||||
|
||||
## The lit campfire you're sheltering by, if any.
|
||||
func player_fire() -> Node3D:
|
||||
var pp: Vector3 = world.player.global_position
|
||||
for f in get_tree().get_nodes_in_group("station:campfire"):
|
||||
if f.station_active() and (f as Node3D).global_position.distance_to(pp) < FIRE_SAFE:
|
||||
return f
|
||||
return null
|
||||
|
||||
func _torch() -> bool:
|
||||
return world.player.hands != null and world.player.hands.held_heat() > 0.0
|
||||
|
||||
## Wolves only close in when you're away from a fire.
|
||||
func may_close_in() -> bool:
|
||||
return player_fire() == null and not world.player.survival.dead
|
||||
|
||||
## Where wolf `w` should be while circling.
|
||||
func circle_point(w: Node) -> Vector3:
|
||||
var fire := player_fire()
|
||||
var center: Vector3 = fire.global_position if fire != null else world.player.global_position
|
||||
var r := FIRE_RING if fire != null else RING * (1.3 if _torch() else 1.0)
|
||||
var live := alive()
|
||||
var n := maxi(live.size(), 1)
|
||||
var a := _circle_a + TAU * float(live.find(w)) / n
|
||||
return center + Vector3(cos(a), 0, sin(a)) * r
|
||||
|
||||
## A wolf asks for its turn to dart in.
|
||||
func request_lunge(w: Node) -> bool:
|
||||
if _lunger != null or _gap > 0.0 or not may_close_in() or alive_count() < 2:
|
||||
return false
|
||||
if w.global_position.distance_to(world.player.global_position) > RING * 1.8:
|
||||
return false
|
||||
_lunger = w
|
||||
return true
|
||||
|
||||
func lunge_done(w: Node) -> void:
|
||||
if _lunger == w:
|
||||
_lunger = null
|
||||
_gap = ATTACK_GAP * (2.5 if _torch() else 1.0) * _rng.randf_range(0.8, 1.3)
|
||||
|
||||
## A wolf was hit: the whole pack turns on you.
|
||||
func alert(_from: Vector3) -> void:
|
||||
for m in alive():
|
||||
m._aggro = maxf(m._aggro, 20.0)
|
||||
|
||||
func howl() -> void:
|
||||
var live := alive()
|
||||
if _audio == null or live.is_empty():
|
||||
return
|
||||
_audio.global_position = (live[0] as Node3D).global_position + Vector3(0, 1, 0)
|
||||
_audio.pitch_scale = _rng.randf_range(0.9, 1.1)
|
||||
_audio.play()
|
||||
|
||||
## A synthesised wolf howl: a rising then falling tone with vibrato and a
|
||||
## breathy edge, ~2.8 s. Built once; no audio files.
|
||||
static func howl_stream() -> AudioStreamWAV:
|
||||
if _howl != null:
|
||||
return _howl
|
||||
var rate := 22050
|
||||
var secs := 2.8
|
||||
var n := int(rate * secs)
|
||||
var data := PackedByteArray()
|
||||
data.resize(n * 2)
|
||||
var phase := 0.0
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = 5
|
||||
var lp := 0.0
|
||||
for i in range(n):
|
||||
var t := float(i) / rate
|
||||
var u := t / secs
|
||||
# Pitch: swoop up, hold, sag at the end.
|
||||
var f := 330.0 + 260.0 * smoothstep(0.0, 0.25, u) - 140.0 * smoothstep(0.65, 1.0, u)
|
||||
f += sin(t * TAU * 5.5) * 6.0 * smoothstep(0.2, 0.4, u)
|
||||
phase += TAU * f / rate
|
||||
var env := smoothstep(0.0, 0.08, u) * (1.0 - smoothstep(0.75, 1.0, u))
|
||||
lp += 0.15 * ((rng.randf() * 2.0 - 1.0) - lp)
|
||||
var s := (sin(phase) * 0.7 + sin(phase * 2.0) * 0.18 + sin(phase * 3.0) * 0.06 + lp * 0.12) * env * 0.6
|
||||
data.encode_s16(i * 2, int(clampf(s, -1.0, 1.0) * 32000.0))
|
||||
var wav := AudioStreamWAV.new()
|
||||
wav.format = AudioStreamWAV.FORMAT_16_BITS
|
||||
wav.mix_rate = rate
|
||||
wav.stereo = false
|
||||
wav.data = data
|
||||
_howl = wav
|
||||
return wav
|
||||
127
scripts/fishing/bobber.gd
Normal file
127
scripts/fishing/bobber.gd
Normal file
@@ -0,0 +1,127 @@
|
||||
extends Node3D
|
||||
## A cast fishing float.
|
||||
##
|
||||
## flying -> it arcs out from the rod tip.
|
||||
## floating -> it landed on deep enough water; a fish bites after a while
|
||||
## (sooner at dawn and dusk, and in the rain).
|
||||
## biting -> the float dips; you have BITE_WINDOW seconds to reel in
|
||||
## (Actions.reel), or the fish gets away and you wait again.
|
||||
## landed -> it fell on dry ground: nothing will bite. Reel in and try again.
|
||||
## Walking too far away snaps the line.
|
||||
##
|
||||
## A line is drawn from the rod tip each frame (hands.gd gives the tip).
|
||||
|
||||
const BITE_WINDOW := 1.5
|
||||
const MIN_DEPTH := 0.5 ## water this deep under the float, or no fish
|
||||
const LINE_LENGTH := 26.0
|
||||
|
||||
var world: Node3D
|
||||
var player: Node3D
|
||||
var state := "flying"
|
||||
var velocity := Vector3.ZERO
|
||||
var fish := "" ## what's on the line while biting
|
||||
var wait_left := 0.0
|
||||
var bite_left := 0.0
|
||||
var rng := RandomNumberGenerator.new()
|
||||
var tip := Vector3.ZERO ## rod tip, world space (set by hands.gd)
|
||||
var _t := 0.0
|
||||
var _mesh: MeshInstance3D
|
||||
var _line: MeshInstance3D
|
||||
var _imesh: ImmediateMesh
|
||||
|
||||
func setup(w: Node3D, p: Node3D, from: Vector3, vel: Vector3, seed: int) -> void:
|
||||
world = w
|
||||
player = p
|
||||
velocity = vel
|
||||
position = from
|
||||
tip = from
|
||||
rng.seed = seed
|
||||
_mesh = MeshInstance3D.new()
|
||||
_mesh.mesh = ItemDB.mesh_for("bobber")
|
||||
_mesh.material_override = ItemDB.item_material()
|
||||
_mesh.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
add_child(_mesh)
|
||||
_imesh = ImmediateMesh.new()
|
||||
_line = MeshInstance3D.new()
|
||||
_line.mesh = _imesh
|
||||
_line.top_level = true
|
||||
_line.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
var lm := StandardMaterial3D.new()
|
||||
lm.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
lm.albedo_color = Color(0.9, 0.9, 0.85, 0.7)
|
||||
lm.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
_line.material_override = lm
|
||||
add_child(_line)
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
step(delta)
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
_draw_line()
|
||||
|
||||
## How long until the next bite (seconds).
|
||||
func _next_wait() -> float:
|
||||
var h: float = world.day_night.hours()
|
||||
var k := 1.0
|
||||
if (h > 5.0 and h < 8.5) or (h > 17.0 and h < 20.5):
|
||||
k *= 0.6 # fish feed at dawn and dusk
|
||||
if world.weather.precip > 0.3:
|
||||
k *= 0.8
|
||||
return rng.randf_range(5.0, 14.0) * k
|
||||
|
||||
func _pick_fish() -> String:
|
||||
var h: float = world.day_night.hours()
|
||||
var trout := 0.4 if (h > 5.0 and h < 8.5) or (h > 17.0 and h < 20.5) else 0.22
|
||||
return "trout" if rng.randf() < trout else "perch"
|
||||
|
||||
func step(dt: float) -> void:
|
||||
_t += dt
|
||||
var terrain: Node3D = world.terrain
|
||||
if global_position.distance_to(player.global_position) > LINE_LENGTH + 4.0:
|
||||
Actions.notify.emit("The line snapped")
|
||||
world.end_fishing()
|
||||
return
|
||||
match state:
|
||||
"flying":
|
||||
velocity.y -= 9.8 * dt
|
||||
var p := global_position + velocity * dt
|
||||
var ground: float = terrain.sample_height(p.x, p.z)
|
||||
if p.y <= terrain.water_level and ground < terrain.water_level - MIN_DEPTH:
|
||||
p.y = terrain.water_level
|
||||
state = "floating"
|
||||
wait_left = _next_wait()
|
||||
elif p.y <= ground + 0.03:
|
||||
p.y = ground + 0.03
|
||||
state = "landed"
|
||||
Actions.notify.emit("Cast into the water (deeper than this)" if ground < terrain.water_level else "Cast into the water")
|
||||
global_position = p
|
||||
"floating":
|
||||
wait_left -= dt
|
||||
_mesh.position.y = sin(_t * 2.2) * 0.012
|
||||
if wait_left <= 0.0:
|
||||
state = "biting"
|
||||
fish = _pick_fish()
|
||||
bite_left = BITE_WINDOW
|
||||
Actions.notify.emit("Bite! Click to reel in")
|
||||
"biting":
|
||||
bite_left -= dt
|
||||
# The float bobs hard and goes under.
|
||||
_mesh.position.y = -0.06 + sin(_t * 18.0) * 0.03
|
||||
if bite_left <= 0.0:
|
||||
state = "floating"
|
||||
fish = ""
|
||||
wait_left = _next_wait() * 1.3
|
||||
_mesh.position.y = 0.0
|
||||
Actions.notify.emit("It got away")
|
||||
|
||||
func _draw_line() -> void:
|
||||
_imesh.clear_surfaces()
|
||||
_imesh.surface_begin(Mesh.PRIMITIVE_LINE_STRIP)
|
||||
var a := tip
|
||||
var b := global_position + Vector3(0, 0.1, 0)
|
||||
for i in range(13):
|
||||
var k := i / 12.0
|
||||
var p := a.lerp(b, k)
|
||||
p.y -= sin(k * PI) * 0.06 * a.distance_to(b) * (0.3 if state == "biting" else 1.0) # slack
|
||||
_imesh.surface_add_vertex(p)
|
||||
_imesh.surface_end()
|
||||
126
scripts/fishing/lake_fish.gd
Normal file
126
scripts/fishing/lake_fish.gd
Normal file
@@ -0,0 +1,126 @@
|
||||
extends Node3D
|
||||
## Fish you can see in the lake, so it's clear there's something to catch.
|
||||
## Purely visual (the catch comes from bobber.gd).
|
||||
##
|
||||
## - A few swim slow loops just under the surface over water at least 0.8 m
|
||||
## deep, including the shelf near the shore where you'll be standing.
|
||||
## - Every few seconds one jumps: a leap out of the water and a spreading
|
||||
## splash ring. That's the cue that tells you where to cast.
|
||||
|
||||
const SCALE := 1.6 ## drawn bigger than the item so they read at a distance
|
||||
|
||||
var terrain: Node3D
|
||||
var _fish: Array = [] ## [MeshInstance3D, centre, radius, speed, phase, depth]
|
||||
var _rng := RandomNumberGenerator.new()
|
||||
var _jump_left := 3.0
|
||||
var _jumping := -1 ## index of the fish mid-leap, or -1
|
||||
var _jump_t := 0.0
|
||||
var _rings: Array = [] ## [MeshInstance3D, age]
|
||||
static var _ring_mesh: Mesh
|
||||
|
||||
func setup(t: Node3D, count: int = 14) -> void:
|
||||
terrain = t
|
||||
_rng.seed = t.noise_seed + 501
|
||||
var lake: Vector2 = t.lake_center
|
||||
var tries := 0
|
||||
while _fish.size() < count and tries < 600:
|
||||
tries += 1
|
||||
var a := _rng.randf() * TAU
|
||||
# Bias toward the outer, shallower ring so some swim near the shore.
|
||||
var r: float = sqrt(_rng.randf()) * t.lake_radius * 0.7
|
||||
var c := Vector2(lake.x + cos(a) * r, lake.y + sin(a) * r)
|
||||
var loop := _rng.randf_range(1.2, 3.0)
|
||||
var ok := true
|
||||
for k in range(8):
|
||||
var q := c + Vector2(cos(k * TAU / 8.0), sin(k * TAU / 8.0)) * loop
|
||||
if t.sample_height(q.x, q.y) > t.water_level - 0.8:
|
||||
ok = false
|
||||
break
|
||||
if not ok:
|
||||
continue
|
||||
var mi := MeshInstance3D.new()
|
||||
mi.mesh = ItemDB.mesh_for("trout" if _rng.randf() < 0.3 else "perch")
|
||||
mi.material_override = ItemDB.item_material()
|
||||
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
mi.scale = Vector3.ONE * SCALE
|
||||
add_child(mi)
|
||||
_fish.append([mi, c, loop, _rng.randf_range(0.25, 0.5) * (1.0 if _rng.randf() < 0.5 else -1.0),
|
||||
_rng.randf() * TAU, _rng.randf_range(0.2, 0.35)])
|
||||
|
||||
## Where fish `i` is in its loop at time `t`.
|
||||
func _swim_pos(i: int, t: float) -> Vector3:
|
||||
var f: Array = _fish[i]
|
||||
var a: float = t * f[3] + f[4]
|
||||
var p: Vector2 = (f[1] as Vector2) + Vector2(cos(a), sin(a)) * float(f[2])
|
||||
return Vector3(p.x, terrain.water_level - f[5] + sin(t * 1.3 + f[4]) * 0.04, p.y)
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
var t := Time.get_ticks_msec() / 1000.0
|
||||
for i in range(_fish.size()):
|
||||
if i == _jumping:
|
||||
continue
|
||||
var f: Array = _fish[i]
|
||||
var mi: MeshInstance3D = f[0]
|
||||
mi.position = _swim_pos(i, t)
|
||||
var a: float = t * f[3] + f[4]
|
||||
# Face along the loop (fish mesh's head is +X), with a little wiggle.
|
||||
var tangent := Vector2(-sin(a), cos(a)) * signf(f[3])
|
||||
mi.rotation = Vector3(0, atan2(-tangent.y, tangent.x) + sin(t * 6.0 + f[4]) * 0.15, 0)
|
||||
_update_jump(delta, t)
|
||||
_update_rings(delta)
|
||||
|
||||
func _update_jump(dt: float, t: float) -> void:
|
||||
if _fish.is_empty():
|
||||
return
|
||||
if _jumping < 0:
|
||||
_jump_left -= dt
|
||||
if _jump_left <= 0.0:
|
||||
_jumping = _rng.randi() % _fish.size()
|
||||
_jump_t = 0.0
|
||||
_ring(_swim_pos(_jumping, t))
|
||||
return
|
||||
# A 0.8 s arc, half a metre up, nose up then nose down.
|
||||
_jump_t += dt
|
||||
var k := _jump_t / 0.8
|
||||
var mi: MeshInstance3D = _fish[_jumping][0]
|
||||
var base := _swim_pos(_jumping, t)
|
||||
mi.position = base + Vector3(0, sin(clampf(k, 0.0, 1.0) * PI) * 0.55 + 0.25, 0)
|
||||
mi.rotation.z = lerpf(1.0, -1.0, clampf(k, 0.0, 1.0))
|
||||
if k >= 1.0:
|
||||
_ring(base)
|
||||
mi.rotation.z = 0.0
|
||||
_jumping = -1
|
||||
_jump_left = _rng.randf_range(3.0, 8.0)
|
||||
|
||||
## A splash ring that spreads and fades on the surface.
|
||||
func _ring(at: Vector3) -> void:
|
||||
if _ring_mesh == null:
|
||||
var tm := TorusMesh.new()
|
||||
tm.inner_radius = 0.42
|
||||
tm.outer_radius = 0.5
|
||||
tm.rings = 24
|
||||
tm.ring_segments = 4
|
||||
var m := StandardMaterial3D.new()
|
||||
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
m.albedo_color = Color(0.9, 0.95, 1.0, 0.8)
|
||||
tm.material = m
|
||||
_ring_mesh = tm
|
||||
var mi := MeshInstance3D.new()
|
||||
mi.mesh = _ring_mesh
|
||||
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
mi.position = Vector3(at.x, terrain.water_level + 0.02, at.z)
|
||||
mi.scale = Vector3(0.4, 0.05, 0.4)
|
||||
add_child(mi)
|
||||
_rings.append([mi, 0.0])
|
||||
|
||||
func _update_rings(dt: float) -> void:
|
||||
for r in _rings.duplicate():
|
||||
r[1] += dt
|
||||
var mi: MeshInstance3D = r[0]
|
||||
var s: float = 0.4 + r[1] * 1.6
|
||||
mi.scale = Vector3(s, 0.05, s)
|
||||
mi.transparency = clampf(r[1] / 1.6, 0.0, 1.0)
|
||||
if r[1] >= 1.6:
|
||||
mi.queue_free()
|
||||
_rings.erase(r)
|
||||
@@ -65,6 +65,14 @@ static func build(key: String) -> ArrayMesh:
|
||||
"bow": m = _bow()
|
||||
"arrow": m = _arrow()
|
||||
"hide_cloak": m = _cloak()
|
||||
"wolf_pelt": m = _pelt(WOLF_FUR)
|
||||
"wolf_cloak": m = _cloak(WOLF_FUR)
|
||||
"fishing_rod": m = _rod()
|
||||
"bobber": m = _bobber()
|
||||
"perch": m = _fish(PERCH, 0.16, false)
|
||||
"trout": m = _fish(TROUT, 0.24, false)
|
||||
"grilled_perch": m = _fish(PERCH, 0.16, true)
|
||||
"grilled_trout": m = _fish(TROUT, 0.24, true)
|
||||
"berry_bush": m = _berry_bush(true)
|
||||
"berry_bush_bare": m = _berry_bush(false)
|
||||
_:
|
||||
@@ -458,12 +466,74 @@ static func _arrow() -> ArrayMesh:
|
||||
P._tri(st, Vector3(0, 0, 0.33), Vector3(0, 0, 0.2), d + Vector3(0, 0, 0.3), Vector3(d.y, -d.x, 0), FLETCH)
|
||||
return P._commit(st)
|
||||
|
||||
static func _cloak() -> ArrayMesh:
|
||||
const WOLF_FUR := Color(0.5, 0.5, 0.52)
|
||||
|
||||
static func _pelt(col: Color) -> ArrayMesh:
|
||||
var st := P._begin()
|
||||
var r := _rng(149)
|
||||
_box(st, Vector3(0, 0.02, 0), Vector3(0.42, 0.035, 0.24), col, Basis(Vector3.UP, 0.15))
|
||||
_box(st, Vector3(0.04, 0.05, 0.01), Vector3(0.3, 0.035, 0.18), _board(r, col), Basis(Vector3.UP, -0.25))
|
||||
_box(st, Vector3(-0.2, 0.03, 0.05), Vector3(0.12, 0.03, 0.05), P._mul(col, 0.8), Basis(Vector3.UP, 0.7)) # tail
|
||||
return P._commit(st)
|
||||
|
||||
static func _cloak(col: Color = HIDE) -> ArrayMesh:
|
||||
var st := P._begin()
|
||||
var r := _rng(139)
|
||||
# Folded cloak: a stack of fur with a bone clasp.
|
||||
for i in range(3):
|
||||
_box(st, Vector3(0, 0.03 + i * 0.045, 0), Vector3(0.42 - i * 0.05, 0.04, 0.3 - i * 0.03), _board(r, HIDE), Basis(Vector3.UP, (i - 1) * 0.08))
|
||||
_box(st, Vector3(0, 0.17, 0.0), Vector3(0.32, 0.05, 0.08), P._mul(FUR, 1.1))
|
||||
_box(st, Vector3(0, 0.03 + i * 0.045, 0), Vector3(0.42 - i * 0.05, 0.04, 0.3 - i * 0.03), _board(r, col), Basis(Vector3.UP, (i - 1) * 0.08))
|
||||
_box(st, Vector3(0, 0.17, 0.0), Vector3(0.32, 0.05, 0.08), P._mul(FUR if col == HIDE else col, 1.1))
|
||||
_box(st, Vector3(0.1, 0.2, 0.05), Vector3(0.05, 0.03, 0.03), BONE)
|
||||
return P._commit(st)
|
||||
|
||||
# --- fishing ------------------------------------------------------------------
|
||||
|
||||
const PERCH := Color(0.45, 0.52, 0.30)
|
||||
const TROUT := Color(0.50, 0.55, 0.58)
|
||||
const GRILLED := Color(0.55, 0.36, 0.18)
|
||||
const FLOAT_RED := Color(0.85, 0.15, 0.12)
|
||||
|
||||
## Rod along +Y from the grip, a tip ring at the top. Rod tip is at
|
||||
## (0, ROD_TIP, 0) (hands.gd draws the line from there).
|
||||
const ROD_TIP := 1.15
|
||||
static func _rod() -> ArrayMesh:
|
||||
var st := P._begin()
|
||||
var r := _rng(151)
|
||||
P._cylinder(st, Vector3(0, -0.15, 0), Vector3(0, ROD_TIP, 0), 0.02, 0.006, 5, HANDLE, r, -1.0, HANDLE)
|
||||
_box(st, Vector3(0, 0.0, 0), Vector3(0.05, 0.16, 0.05), BINDING)
|
||||
# A simple wooden reel with line wound on it.
|
||||
P._cylinder(st, Vector3(-0.05, 0.12, 0.04), Vector3(0.05, 0.12, 0.04), 0.045, 0.045, 7, PLANK_DARK, r, 0.0, LINEN)
|
||||
return P._commit(st)
|
||||
|
||||
static func _bobber() -> ArrayMesh:
|
||||
var st := P._begin()
|
||||
var r := _rng(157)
|
||||
var n := FastNoiseLite.new()
|
||||
# Big enough to spot 10 m out on the water.
|
||||
P._blob(st, Vector3(0, 0.03, 0), Vector3(0.08, 0.06, 0.08), n, 0.0, Color(0.95, 0.95, 0.92), r, 1)
|
||||
P._blob(st, Vector3(0, 0.09, 0), Vector3(0.07, 0.05, 0.07), n, 0.0, FLOAT_RED, r, 1)
|
||||
P._cylinder(st, Vector3(0, 0.11, 0), Vector3(0, 0.24, 0), 0.01, 0.007, 4, FLOAT_RED, r)
|
||||
return P._commit(st)
|
||||
|
||||
## A fish lying on its side along X, head at +X. `len` is half its length.
|
||||
static func _fish(col: Color, len: float, grilled: bool) -> ArrayMesh:
|
||||
var st := P._begin()
|
||||
var r := _rng(163 + int(len * 100.0))
|
||||
var n := FastNoiseLite.new()
|
||||
n.seed = 163
|
||||
var body := GRILLED.lerp(col, 0.25) if grilled else col
|
||||
P._blob(st, Vector3(0, len * 0.3, 0), Vector3(len, len * 0.32, len * 0.14), n, 0.05, body, r, 1)
|
||||
P._blob(st, Vector3(len * 0.1, len * 0.22, 0), Vector3(len * 0.75, len * 0.18, len * 0.12), n, 0.05,
|
||||
P._mul(body, 1.25) if not grilled else P._mul(body, 0.8), r, 1) # pale belly / char
|
||||
# Tail fin.
|
||||
var t0 := Vector3(-len * 0.9, len * 0.3, 0)
|
||||
var fin := P._mul(body, 0.8)
|
||||
P._tri(st, t0, Vector3(-len * 1.35, len * 0.65, 0), Vector3(-len * 1.35, -len * 0.05, 0), Vector3(0, 0, 1), fin)
|
||||
P._tri(st, t0, Vector3(-len * 1.35, len * 0.65, 0), Vector3(-len * 1.35, -len * 0.05, 0), Vector3(0, 0, -1), fin)
|
||||
# Dorsal fin and an eye.
|
||||
P._tri(st, Vector3(len * 0.3, len * 0.58, 0), Vector3(-len * 0.3, len * 0.58, 0), Vector3(-len * 0.1, len * 0.85, 0), Vector3(0, 0, 1), fin)
|
||||
P._tri(st, Vector3(len * 0.3, len * 0.58, 0), Vector3(-len * 0.3, len * 0.58, 0), Vector3(-len * 0.1, len * 0.85, 0), Vector3(0, 0, -1), fin)
|
||||
if not grilled:
|
||||
for s in [-1.0, 1.0]:
|
||||
P._blob(st, Vector3(len * 0.75, len * 0.38, s * len * 0.11), Vector3.ONE * len * 0.06, n, 0.0, Color(0.05, 0.05, 0.05), r, 0)
|
||||
return P._commit(st)
|
||||
|
||||
@@ -12,6 +12,7 @@ signal prompt_changed(text: String)
|
||||
|
||||
const Campfire = preload("res://scripts/building/campfire.gd")
|
||||
const BuildPiece = preload("res://scripts/building/build_piece.gd")
|
||||
const ItemMeshesScript = preload("res://scripts/items/item_meshes.gd")
|
||||
const REACH := 3.2
|
||||
const PLACE_REACH := 6.0
|
||||
const SWING_TIME := 0.5
|
||||
@@ -173,8 +174,24 @@ func _physics_process(delta: float) -> void:
|
||||
_set_prompt("Drawing %d%%" % roundi(draw_power() * 100.0) if _drawing else "[hold LMB] Draw (%d arrows)" % n)
|
||||
elif def.get("weapon", {}).has("throw"):
|
||||
_set_prompt("[LMB] Thrust [RMB] Throw")
|
||||
elif def.has("fishing"):
|
||||
var b: Node3D = player.get_parent().bobber
|
||||
if b == null or not is_instance_valid(b):
|
||||
_set_prompt("[LMB] Cast into the lake")
|
||||
else:
|
||||
match b.state:
|
||||
"biting": _set_prompt("Bite! [LMB] Reel in now")
|
||||
"floating": _set_prompt("Waiting for a bite... [LMB] Reel in")
|
||||
_: _set_prompt("[LMB] Reel in")
|
||||
else:
|
||||
_set_prompt("")
|
||||
# The line runs from the rod tip; switching away from the rod reels in.
|
||||
var bob: Node3D = player.get_parent().bobber
|
||||
if bob != null and is_instance_valid(bob):
|
||||
if def.has("fishing"):
|
||||
bob.tip = rod_tip()
|
||||
else:
|
||||
player.get_parent().end_fishing()
|
||||
if _drawing:
|
||||
if not def.has("bow"):
|
||||
_drawing = false # switched away mid-draw
|
||||
@@ -252,9 +269,32 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
Actions.notify.emit("Can't place that here")
|
||||
elif _held_def().has("bow"):
|
||||
_start_draw()
|
||||
elif _held_def().has("fishing"):
|
||||
_fish_click()
|
||||
else:
|
||||
swing()
|
||||
|
||||
# --- fishing ----------------------------------------------------------------------
|
||||
|
||||
func _fish_click() -> void:
|
||||
var w: Node3D = player.get_parent()
|
||||
if w.bobber != null and is_instance_valid(w.bobber):
|
||||
Actions.reel(player, w)
|
||||
return
|
||||
if _swing_cd > 0.0:
|
||||
return
|
||||
var a := _aim()
|
||||
if Actions.cast_line(player, rod_tip(), a[1], w):
|
||||
_swing_cd = 0.6
|
||||
var tw := create_tween()
|
||||
var back := Transform3D(_view_rest.basis.rotated(Vector3.RIGHT, deg_to_rad(40)), _view_rest.origin)
|
||||
tw.tween_property(_view, "transform", back, 0.12)
|
||||
tw.tween_property(_view, "transform", _view_rest, 0.25).set_trans(Tween.TRANS_SINE)
|
||||
|
||||
## World position of the held rod's tip (where the line starts).
|
||||
func rod_tip() -> Vector3:
|
||||
return _view.global_transform * Vector3(0, ItemMeshesScript.ROD_TIP, 0)
|
||||
|
||||
# --- bow and spear --------------------------------------------------------------
|
||||
|
||||
func _aim() -> Array:
|
||||
|
||||
@@ -42,6 +42,35 @@ var _hurt_cd := 0.0 ## no natural healing for a few seconds after a hit
|
||||
signal hurt(amount: float, from: Vector3)
|
||||
const HURT_NO_REGEN := 6.0
|
||||
|
||||
## Mixed diet: each food group (meat, fish, greens) has a level that runs
|
||||
## down over about a day and a half. Food from a group tops it up.
|
||||
## - One group low: you don't heal naturally ("Poor diet").
|
||||
## - Two or more low: Malnourished. Health slowly drains, stamina recovers
|
||||
## slower. Living on berries alone will kill you.
|
||||
## - All three kept up: Balanced diet, healing x1.5.
|
||||
const DIET_GROUPS := ["meat", "fish", "plants"]
|
||||
const DIET_NAMES := {"meat": "meat", "fish": "fish", "plants": "greens"}
|
||||
const DIET_DRAIN := 100.0 / 1800.0
|
||||
const DIET_LOW := 15.0
|
||||
const DIET_GOOD := 40.0
|
||||
const DIET_START := 60.0
|
||||
const MALNOURISHED_DRAIN := 0.12
|
||||
var diet := {"meat": DIET_START, "fish": DIET_START, "plants": DIET_START}
|
||||
|
||||
## The groups that are running low right now.
|
||||
func diet_lacking() -> Array:
|
||||
var out := []
|
||||
for g in DIET_GROUPS:
|
||||
if float(diet[g]) < DIET_LOW:
|
||||
out.append(g)
|
||||
return out
|
||||
|
||||
func balanced_diet() -> bool:
|
||||
for g in DIET_GROUPS:
|
||||
if float(diet[g]) < DIET_GOOD:
|
||||
return false
|
||||
return true
|
||||
|
||||
var player: CharacterBody3D
|
||||
var world: Node3D
|
||||
var _stamina_wait := 0.0
|
||||
@@ -85,7 +114,13 @@ func step(dt: float) -> void:
|
||||
if _stamina_wait > 0.0:
|
||||
_stamina_wait -= dt
|
||||
else:
|
||||
stamina = minf(stamina + STAMINA_REGEN * (1.5 if rested > 0.0 else 1.0) * buff_mult("stamina_regen") * dt, MAX)
|
||||
var malnourished := diet_lacking().size() >= 2
|
||||
stamina = minf(stamina + STAMINA_REGEN * (1.5 if rested > 0.0 else 1.0) * buff_mult("stamina_regen")
|
||||
* (0.7 if malnourished else 1.0) * dt, MAX)
|
||||
|
||||
for g in DIET_GROUPS:
|
||||
diet[g] = maxf(float(diet[g]) - DIET_DRAIN * dt, 0.0)
|
||||
var lacking := diet_lacking()
|
||||
|
||||
for id in buffs.keys():
|
||||
buffs[id].left -= dt
|
||||
@@ -98,10 +133,13 @@ func step(dt: float) -> void:
|
||||
dmg += 0.6
|
||||
if warmth < 12.0:
|
||||
dmg += 0.5
|
||||
if lacking.size() >= 2:
|
||||
dmg += MALNOURISHED_DRAIN
|
||||
if dmg > 0.0:
|
||||
health = maxf(health - dmg * dt, 0.0)
|
||||
elif hunger > 40.0 and warmth > 35.0 and _hurt_cd <= 0.0:
|
||||
health = minf(health + 0.5 * buff_mult("health_regen") * dt, MAX)
|
||||
elif hunger > 40.0 and warmth > 35.0 and _hurt_cd <= 0.0 and lacking.is_empty():
|
||||
var diet_mult := 1.5 if balanced_diet() else 1.0
|
||||
health = minf(health + 0.5 * buff_mult("health_regen") * diet_mult * dt, MAX)
|
||||
|
||||
_update_statuses()
|
||||
changed.emit()
|
||||
@@ -123,6 +161,14 @@ func _update_statuses() -> void:
|
||||
statuses.append("Hungry")
|
||||
if wet > 0.0:
|
||||
statuses.append("Wet")
|
||||
var lacking := diet_lacking()
|
||||
if not lacking.is_empty():
|
||||
var need: Array[String] = []
|
||||
for g in lacking:
|
||||
need.append(DIET_NAMES[g])
|
||||
statuses.append("%s (need %s)" % ["Malnourished" if lacking.size() >= 2 else "Poor diet", ", ".join(need)])
|
||||
elif balanced_diet():
|
||||
statuses.append("Balanced diet")
|
||||
if sheltered:
|
||||
statuses.append("Sheltered")
|
||||
if rested > 0.0:
|
||||
@@ -145,6 +191,11 @@ func eat(food: Dictionary) -> void:
|
||||
health = clampf(health + float(food.get("health", 0)), minf(health, 1.0), MAX)
|
||||
if food.has("buff"):
|
||||
add_buff(food.buff)
|
||||
var d: Dictionary = food.get("diet", {})
|
||||
for g in d:
|
||||
if diet.has(g):
|
||||
diet[g] = minf(float(diet[g]) + float(d[g]), MAX)
|
||||
_update_statuses()
|
||||
changed.emit()
|
||||
|
||||
func soak() -> void:
|
||||
@@ -168,6 +219,9 @@ func reset_after_death() -> void:
|
||||
warmth = 60.0
|
||||
wet = 0.0
|
||||
buffs.clear()
|
||||
# Don't respawn straight back into malnutrition.
|
||||
for g in DIET_GROUPS:
|
||||
diet[g] = maxf(float(diet[g]), 30.0)
|
||||
changed.emit()
|
||||
|
||||
## Takes a hit (a boar, later wolves). Pauses natural healing briefly.
|
||||
@@ -198,7 +252,7 @@ func add_buff(b: Dictionary) -> void:
|
||||
|
||||
func to_data() -> Dictionary:
|
||||
return {"health": health, "stamina": stamina, "hunger": hunger, "warmth": warmth, "wet": wet, "rested": rested,
|
||||
"buffs": buffs.duplicate(true)}
|
||||
"buffs": buffs.duplicate(true), "diet": diet.duplicate()}
|
||||
|
||||
func load_data(d: Dictionary) -> void:
|
||||
health = float(d.get("health", health))
|
||||
@@ -208,5 +262,9 @@ func load_data(d: Dictionary) -> void:
|
||||
wet = float(d.get("wet", 0.0))
|
||||
rested = float(d.get("rested", 0.0))
|
||||
buffs = (d.get("buffs", {}) as Dictionary).duplicate(true)
|
||||
# Older saves have no diet: start it where a new world does.
|
||||
var sd: Dictionary = d.get("diet", {})
|
||||
for g in DIET_GROUPS:
|
||||
diet[g] = float(sd.get(g, DIET_START))
|
||||
dead = false
|
||||
changed.emit()
|
||||
|
||||
@@ -16,6 +16,7 @@ var _toasts: VBoxContainer
|
||||
var _screen: Control
|
||||
var _menu: Control
|
||||
var _bars: Dictionary = {}
|
||||
var _diet_bars: Dictionary = {}
|
||||
var _status: Label
|
||||
var _clock: Label
|
||||
var _hurt: ColorRect
|
||||
@@ -142,6 +143,29 @@ func _build_stats(root: Control) -> void:
|
||||
row.add_child(bar)
|
||||
box.add_child(row)
|
||||
_bars[spec[0]] = bar
|
||||
# Diet: three short bars (meat, fish, greens), so a gap shows early.
|
||||
var drow := HBoxContainer.new()
|
||||
drow.add_theme_constant_override("separation", 6)
|
||||
drow.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
var dl := UITheme.label("Diet", 14, UITheme.TEXT_DIM)
|
||||
dl.custom_minimum_size = Vector2(64, 0)
|
||||
drow.add_child(dl)
|
||||
for spec in [["meat", "Meat", Color(0.75, 0.3, 0.25)], ["fish", "Fish", Color(0.35, 0.6, 0.85)], ["plants", "Greens", Color(0.45, 0.75, 0.3)]]:
|
||||
var cell := VBoxContainer.new()
|
||||
cell.add_theme_constant_override("separation", 0)
|
||||
cell.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
var bar := ProgressBar.new()
|
||||
bar.custom_minimum_size = Vector2(58, 8)
|
||||
bar.show_percentage = false
|
||||
bar.max_value = 100.0
|
||||
bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
bar.add_theme_stylebox_override("background", UITheme.box(Color(0, 0, 0, 0.55), UITheme.SLOT_BORDER, 1, 3, 0))
|
||||
bar.add_theme_stylebox_override("fill", UITheme.box(spec[2], spec[2].lightened(0.2), 1, 3, 0))
|
||||
cell.add_child(bar)
|
||||
cell.add_child(UITheme.label(spec[1], 11, UITheme.TEXT_DIM))
|
||||
drow.add_child(cell)
|
||||
_diet_bars[spec[0]] = bar
|
||||
box.add_child(drow)
|
||||
_clock = UITheme.label("", 18)
|
||||
_clock.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
|
||||
_clock.grow_horizontal = Control.GROW_DIRECTION_BEGIN
|
||||
@@ -154,6 +178,10 @@ func _process(_d: float) -> void:
|
||||
var s = player.survival
|
||||
for k in _bars:
|
||||
_bars[k].value = s.get(k)
|
||||
for g in _diet_bars:
|
||||
_diet_bars[g].value = s.diet[g]
|
||||
# A group running low blinks, so you notice before it hurts.
|
||||
_diet_bars[g].modulate.a = 0.45 + 0.55 * absf(sin(Time.get_ticks_msec() / 300.0)) if s.diet[g] < s.DIET_LOW else 1.0
|
||||
# Warmth bar shifts from icy blue (cold) to orange (warm).
|
||||
var w: float = s.warmth / 100.0
|
||||
var wc := Color(0.35, 0.6, 0.95).lerp(Color(0.95, 0.55, 0.3), w)
|
||||
|
||||
@@ -10,6 +10,7 @@ const CONTROLS := [
|
||||
["Mouse", "Look"], ["E", "Interact / pick up / add fuel"], ["Left click", "Use tool / place"],
|
||||
["Right click (holding food)", "Eat"], ["Right click (spear / cloak)", "Throw / wear"],
|
||||
["Hold left click (bow)", "Draw, release to shoot"],
|
||||
["Left click (fishing rod)", "Cast / reel in when the float dips"],
|
||||
["R", "Rotate piece (building pieces: quarter turns)"], ["X", "Dismantle the piece you're looking at"],
|
||||
["E on a bed", "Set spawn point / sleep (night, under a roof)"],
|
||||
["1 - 8, wheel", "Hotbar"], ["Tab", "Inventory & crafting (shift-click Craft for 5)"],
|
||||
|
||||
@@ -759,12 +759,17 @@ func _make_grass_tile(k: Vector2i) -> Node3D:
|
||||
## half width]. Set by world.gd whenever floors are built or removed; only the
|
||||
## grass tiles around old and new floors are rebuilt.
|
||||
var _grass_cuts: Array = []
|
||||
## Permanent cuts (the wolf den floor): [transform, half width x, half depth z].
|
||||
var static_cuts: Array = []
|
||||
|
||||
func set_grass_cuts(xforms: Array, half: float) -> void:
|
||||
var old := _grass_cuts
|
||||
_grass_cuts = []
|
||||
for xf: Transform3D in xforms:
|
||||
_grass_cuts.append([xf.affine_inverse(), xf.origin, half])
|
||||
_grass_cuts.append([xf.affine_inverse(), xf.origin, half, half])
|
||||
for c in static_cuts:
|
||||
var xf: Transform3D = c[0]
|
||||
_grass_cuts.append([xf.affine_inverse(), xf.origin, float(c[1]), float(c[2])])
|
||||
var touched := {}
|
||||
for c in old + _grass_cuts:
|
||||
var p: Vector3 = c[1]
|
||||
@@ -783,7 +788,7 @@ func set_grass_cuts(xforms: Array, half: float) -> void:
|
||||
static func _under_cut(cuts: Array, x: float, z: float) -> bool:
|
||||
for c in cuts:
|
||||
var l: Vector3 = (c[0] as Transform3D) * Vector3(x, (c[1] as Vector3).y, z)
|
||||
if absf(l.x) < c[2] and absf(l.z) < c[2]:
|
||||
if absf(l.x) < c[2] and absf(l.z) < c[3]:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
@@ -47,6 +47,9 @@ var weather_fx: Node3D ## weather_fx.gd: rain/snow particles, sound
|
||||
var build: Node ## build_system.gd: snapping, support, shelter
|
||||
var creatures: Node3D ## spawner.gd: wildlife around the player
|
||||
var _dropped: Node3D ## arrows, spears and other dropped items
|
||||
var den: Node3D ## den.gd: the wolves' cave at the foot of the rim
|
||||
var bobber: Node3D ## bobber.gd: the cast fishing float, if any
|
||||
var wolves: Node3D ## wolf_pack.gd (null if this seed has no den site)
|
||||
var has_bed_spawn := false
|
||||
var bed_spawn := Vector3.ZERO ## position of the bed you respawn at
|
||||
var _loading := false ## applying a save: defer support recompute
|
||||
@@ -193,6 +196,10 @@ func sleep_block_reason(bed: Node3D) -> String:
|
||||
return "You can only sleep at night (after 19:30)"
|
||||
if not build.sheltered_at(bed.global_position):
|
||||
return "Your bed needs a roof and walls around it"
|
||||
if wolves != null:
|
||||
for w in wolves.alive():
|
||||
if w.state in ["stalk", "lunge", "defend", "retreat"] and w.global_position.distance_to(bed.global_position) < 40.0:
|
||||
return "You can't sleep with wolves prowling outside"
|
||||
return ""
|
||||
|
||||
## Sleeps until 06:00: the clock, fires, regrowth and weather all move on.
|
||||
@@ -227,6 +234,18 @@ func _on_terrain_ready() -> void:
|
||||
_spawn_yaw = atan2(-to_lake.x, -to_lake.y)
|
||||
player.water_level = terrain.water_level
|
||||
props.place_on(terrain, player if not menu_mode else _menu_cam, _spawn)
|
||||
# The wolf den: part of the scenery, so it's there from day 1 (empty).
|
||||
den = preload("res://scripts/creatures/den.gd").new()
|
||||
den.name = "WolfDen"
|
||||
add_child(den)
|
||||
if den.place(self, props._rock_mat):
|
||||
props.static_cuts.append([den.global_transform, 2.2, 3.4])
|
||||
_update_grass_cuts()
|
||||
if DisplayServer.get_name() != "headless":
|
||||
var fish := preload("res://scripts/fishing/lake_fish.gd").new()
|
||||
fish.name = "LakeFish"
|
||||
add_child(fish)
|
||||
fish.setup(terrain)
|
||||
print("[world] scenery placed in %d ms" % (Time.get_ticks_msec() - t0))
|
||||
if menu_mode:
|
||||
return
|
||||
@@ -242,6 +261,11 @@ func _on_terrain_ready() -> void:
|
||||
if not "--no-creatures" in OS.get_cmdline_user_args():
|
||||
creatures.populate()
|
||||
creatures.enabled = true
|
||||
if den.found:
|
||||
wolves = preload("res://scripts/creatures/wolf_pack.gd").new()
|
||||
wolves.name = "Wolves"
|
||||
add_child(wolves)
|
||||
wolves.setup(self, den)
|
||||
_maybe_capture()
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
@@ -474,6 +498,19 @@ func drop_item(id: String, count: int, xf: Transform3D) -> Node3D:
|
||||
pk.global_transform = xf
|
||||
return pk
|
||||
|
||||
## Casts a fishing float (Actions validated it). One at a time.
|
||||
func start_fishing(p: Node3D, origin: Vector3, vel: Vector3) -> Node3D:
|
||||
end_fishing()
|
||||
bobber = preload("res://scripts/fishing/bobber.gd").new()
|
||||
bobber.setup(self, p, origin, vel, randi())
|
||||
add_child(bobber)
|
||||
return bobber
|
||||
|
||||
func end_fishing() -> void:
|
||||
if bobber != null and is_instance_valid(bobber):
|
||||
bobber.queue_free()
|
||||
bobber = null
|
||||
|
||||
## Fires an arrow or thrown spear (Actions validated it).
|
||||
func launch(id: String, origin: Vector3, vel: Vector3, damage: float, shooter: Node3D) -> Node3D:
|
||||
var pr: Node3D = preload("res://scripts/creatures/projectile.gd").new()
|
||||
@@ -643,6 +680,103 @@ func _capture_survival(prefix: String, cam: Camera3D) -> void:
|
||||
hud._menu.close()
|
||||
await _capture_weather(prefix, cam)
|
||||
await _capture_creatures(prefix, cam)
|
||||
await _capture_wolves_fishing(prefix, cam)
|
||||
|
||||
## The den by day with the pack resting, wolves stalking at night, and
|
||||
## fishing from the lake shore.
|
||||
func _capture_wolves_fishing(prefix: String, cam: Camera3D) -> void:
|
||||
if wolves == null:
|
||||
return
|
||||
for c in creatures.get_children():
|
||||
c.queue_free()
|
||||
wolves.enabled = false
|
||||
day_night.day = 5
|
||||
day_night.time = 10.0 / 24.0
|
||||
day_night.apply(true)
|
||||
wolves.step(0.1)
|
||||
var out: Vector3 = (den.mouth_pos() - den.position).normalized()
|
||||
var eye: Vector3 = den.position + Basis(Vector3.UP, 0.35) * out * 11.0
|
||||
eye.y = terrain.sample_height(eye.x, eye.z) + 1.7
|
||||
player.select(0)
|
||||
_aim_camera(cam, eye, den.position + Vector3(0, 1.0, 0))
|
||||
for w in wolves.alive():
|
||||
w.set_physics_process(false)
|
||||
for _i in range(30):
|
||||
w._animate(0.1)
|
||||
await get_tree().create_timer(1.5).timeout
|
||||
await RenderingServer.frame_post_draw
|
||||
get_viewport().get_texture().get_image().save_png("%s_den.png" % prefix)
|
||||
print("[world] saved %s_den.png" % prefix)
|
||||
|
||||
# Night: the pack circling you in the open.
|
||||
var site: Vector3 = build.debug_find_site(_spawn + Vector3(-10, 0, 10), 6.0)
|
||||
site.y = terrain.sample_height(site.x, site.z)
|
||||
day_night.time = 22.5 / 24.0
|
||||
day_night.apply(true)
|
||||
var live: Array = wolves.alive()
|
||||
for i in range(live.size()):
|
||||
var a := -0.5 + i * 0.7
|
||||
var p: Vector3 = site + Vector3(sin(a), 0, -cos(a)) * (7.0 + i * 1.5)
|
||||
live[i].global_position = Vector3(p.x, terrain.sample_height(p.x, p.z), p.z)
|
||||
live[i].state = "stalk"
|
||||
var to: Vector3 = site - p
|
||||
live[i].yaw = atan2(-to.x, -to.z)
|
||||
live[i].rotation.y = live[i].yaw
|
||||
live[i]._visual.position.y = 0.0
|
||||
for l in live[i]._legs:
|
||||
l.rotation.x = 0.0
|
||||
player.inventory.set_slot(1, {"id": "torch", "count": 1})
|
||||
player.select(1)
|
||||
var e2: Vector3 = site + Vector3(0, 1.7, 0)
|
||||
_aim_camera(cam, e2, site + Vector3(0, 0.6, -8.0))
|
||||
await get_tree().create_timer(1.5).timeout
|
||||
await RenderingServer.frame_post_draw
|
||||
get_viewport().get_texture().get_image().save_png("%s_wolves_night.png" % prefix)
|
||||
print("[world] saved %s_wolves_night.png" % prefix)
|
||||
for w in live:
|
||||
w.global_position = den.slot_pos(w.slot)
|
||||
|
||||
# Fishing at the lake in the evening light, a fish on the line.
|
||||
day_night.time = 18.2 / 24.0
|
||||
day_night.apply(true)
|
||||
var lake: Vector2 = terrain.lake_center
|
||||
var x: float = lake.x
|
||||
while terrain.sample_height(x, lake.y) < terrain.water_level + 0.4 and x < lake.x + 60.0:
|
||||
x += 0.5
|
||||
var shore := Vector3(x + 1.0, 0, lake.y)
|
||||
shore.y = terrain.sample_height(shore.x, shore.z)
|
||||
player.inventory.set_slot(2, {"id": "fishing_rod", "count": 1})
|
||||
player.select(2)
|
||||
var fe: Vector3 = shore + Vector3(0, 1.7, 0)
|
||||
# Midday, looking at the nearest fish, one mid-jump.
|
||||
var lf: Node = get_node_or_null("LakeFish")
|
||||
if lf != null and not lf._fish.is_empty():
|
||||
day_night.time = 12.5 / 24.0
|
||||
day_night.apply(true)
|
||||
var t_now := Time.get_ticks_msec() / 1000.0
|
||||
var best := 0
|
||||
for i in range(lf._fish.size()):
|
||||
if lf._swim_pos(i, t_now).distance_to(fe) < lf._swim_pos(best, t_now).distance_to(fe):
|
||||
best = i
|
||||
_aim_camera(cam, fe, lf._swim_pos(best, t_now))
|
||||
lf._jump_left = 0.4
|
||||
await get_tree().create_timer(0.75).timeout
|
||||
await RenderingServer.frame_post_draw
|
||||
get_viewport().get_texture().get_image().save_png("%s_lake_fish.png" % prefix)
|
||||
print("[world] saved %s_lake_fish.png (fish %.0f m away)" % [prefix, lf._swim_pos(best, t_now).distance_to(fe)])
|
||||
day_night.time = 18.2 / 24.0
|
||||
day_night.apply(true)
|
||||
_aim_camera(cam, fe, Vector3(lake.x + 6.0, terrain.water_level - 1.5, lake.y + 2.0))
|
||||
await get_tree().create_timer(0.3).timeout
|
||||
start_fishing(player, player.hands.rod_tip(), Vector3.ZERO)
|
||||
bobber.set_physics_process(false)
|
||||
bobber.global_position = Vector3(lake.x + 9.0, terrain.water_level, lake.y + 1.5)
|
||||
bobber.state = "biting"
|
||||
await get_tree().create_timer(1.5).timeout
|
||||
await RenderingServer.frame_post_draw
|
||||
get_viewport().get_texture().get_image().save_png("%s_fishing.png" % prefix)
|
||||
print("[world] saved %s_fishing.png" % prefix)
|
||||
end_fishing()
|
||||
|
||||
## Wildlife views: a rabbit, a deer and a boar in a meadow, the bow drawn on
|
||||
## a deer, and a carcass with an arrow in it.
|
||||
|
||||
Reference in New Issue
Block a user