M1d: creatures, hunting, weapons, food buffs and the hide cloak
- Rabbits, deer and boars with procedural meshes and animated legs, a state machine (idle/wander/flee/charge/dead) and heightmap steering that avoids water, cliffs, the world edge, trees and buildings - Spawner keeps a habitat-based budget around the player (6/4/3) - Melee needs a weapon or tool (club, spear, axe, pickaxe); box sweep hits - Wooden spear (thrust or throw) and crude bow with draw-scaled arrows; projectiles stick in the world as saved pickups - Carcasses: E to butcher for meat, hide and embedded arrows - Boars charge and gore when hit; hurt flash and a brief healing pause - Cooked meat gives Well fed (faster stamina and healing); buffs saved - Hide cloak: wear for warmth, saved, goes in the grave on death - Feathers as meadow pickups (appended, existing save ids unchanged) - New test_creatures_combat suite; run_all.sh gets a per-suite time limit
This commit is contained in:
358
scripts/creatures/creature.gd
Normal file
358
scripts/creatures/creature.gd
Normal file
@@ -0,0 +1,358 @@
|
||||
extends AnimatableBody3D
|
||||
## A wild animal: rabbit, deer or boar.
|
||||
##
|
||||
## Movement is steering over the heightmap, with no navmesh. The animal walks
|
||||
## toward a heading, probes the ground ahead (water, cliffs, the world edge,
|
||||
## trees, rocks and buildings) and turns away from anything it can't cross.
|
||||
##
|
||||
## States:
|
||||
## - idle: stands and grazes.
|
||||
## - wander: ambles in one direction.
|
||||
## - flee: runs away from a threat. Rabbits zig-zag.
|
||||
## - charge: a hit boar runs at you and gores you.
|
||||
## - dead: a carcass. E butchers it for meat and hide, and gives back any
|
||||
## arrows stuck in it.
|
||||
##
|
||||
## step(dt) holds all the logic so tests can drive it directly. Lives on
|
||||
## physics layer 4 (value 8): hit by weapons and arrows, but the player walks
|
||||
## through it. A carcass moves to layer 2, like other interactables.
|
||||
|
||||
signal died(creature: Node)
|
||||
|
||||
const Meshes = preload("res://scripts/creatures/creature_meshes.gd")
|
||||
const LAYER := 8
|
||||
|
||||
## Tuning per kind. Speeds in m/s (the player walks at 5 and sprints at 8).
|
||||
const KINDS := {
|
||||
"rabbit": {"name": "Rabbit", "hp": 10.0, "walk": 1.4, "run": 7.0, "turn": 9.0,
|
||||
"notice": 7.0, "notice_loud": 12.0, "calm": 18.0,
|
||||
"drops": {"raw_meat": [1, 1]}},
|
||||
"deer": {"name": "Deer", "hp": 40.0, "walk": 1.3, "run": 9.0, "turn": 4.5,
|
||||
"notice": 16.0, "notice_loud": 30.0, "calm": 45.0,
|
||||
"drops": {"raw_meat": [2, 3], "hide": [1, 2]}},
|
||||
"boar": {"name": "Boar", "hp": 60.0, "walk": 1.2, "run": 6.6, "turn": 5.0,
|
||||
"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]}},
|
||||
}
|
||||
const CARCASS_SECONDS := 300.0
|
||||
|
||||
var kind := "deer"
|
||||
var def: Dictionary
|
||||
var hp := 40.0
|
||||
var state := "idle"
|
||||
var world: Node3D
|
||||
var terrain: Node3D
|
||||
var player: Node3D
|
||||
var rng := RandomNumberGenerator.new()
|
||||
var embedded := {} ## item id -> count (arrows stuck in it)
|
||||
var carcass_left := CARCASS_SECONDS
|
||||
|
||||
var yaw := 0.0
|
||||
var target_yaw := 0.0
|
||||
var speed := 0.0
|
||||
var _state_t := 0.0
|
||||
var _threat := Vector3.ZERO
|
||||
var _aggro := 0.0
|
||||
var _attack_cd := 0.0
|
||||
var _zig := 0.0
|
||||
var _phase := 0.0
|
||||
var _visual: Node3D
|
||||
var _legs: Array[Node3D] = []
|
||||
var _flash := 0.0
|
||||
var _mat: StandardMaterial3D
|
||||
var _hit_mat: StandardMaterial3D
|
||||
|
||||
func setup(k: String, w: Node3D, pos: Vector3, seed: int) -> void:
|
||||
kind = k
|
||||
def = KINDS[k]
|
||||
hp = def.hp
|
||||
world = w
|
||||
terrain = w.terrain
|
||||
player = w.player
|
||||
rng.seed = seed
|
||||
name = "%s_%d" % [k, absi(seed) % 100000]
|
||||
collision_layer = LAYER
|
||||
collision_mask = 0
|
||||
sync_to_physics = false
|
||||
add_to_group("creature")
|
||||
var parts: Dictionary = Meshes.build(k)
|
||||
_mat = ItemDB.item_material()
|
||||
_hit_mat = StandardMaterial3D.new()
|
||||
_hit_mat.vertex_color_use_as_albedo = true
|
||||
_hit_mat.vertex_color_is_srgb = true
|
||||
_hit_mat.albedo_color = Color(1.6, 0.5, 0.45)
|
||||
_visual = Node3D.new()
|
||||
add_child(_visual)
|
||||
var body := MeshInstance3D.new()
|
||||
body.mesh = parts.body
|
||||
body.material_override = _mat
|
||||
_visual.add_child(body)
|
||||
for h: Vector3 in parts.hips:
|
||||
var pivot := Node3D.new()
|
||||
pivot.position = h
|
||||
var leg := MeshInstance3D.new()
|
||||
leg.mesh = parts.leg
|
||||
leg.material_override = _mat
|
||||
pivot.add_child(leg)
|
||||
_visual.add_child(pivot)
|
||||
_legs.append(pivot)
|
||||
var cs := CollisionShape3D.new()
|
||||
var cap := CapsuleShape3D.new()
|
||||
cap.radius = parts.radius
|
||||
cap.height = maxf(parts.height, parts.radius * 2.0 + 0.01)
|
||||
cs.shape = cap
|
||||
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
|
||||
cs.rotation.x = PI * 0.5
|
||||
cs.position = Vector3(0, parts.height * 0.55, 0)
|
||||
add_child(cs)
|
||||
yaw = rng.randf() * TAU
|
||||
target_yaw = yaw
|
||||
_state_t = rng.randf_range(1.0, 4.0)
|
||||
position = Vector3(pos.x, terrain.sample_height(pos.x, pos.z), pos.z) # (spawner sits at the origin)
|
||||
rotation.y = yaw
|
||||
|
||||
func display_name() -> String:
|
||||
return def.name
|
||||
|
||||
func is_dead() -> bool:
|
||||
return state == "dead"
|
||||
|
||||
func interact_prompt(p: Node) -> String:
|
||||
if is_dead():
|
||||
return "%s carcass [E] Butcher" % def.name
|
||||
var s: String = def.name
|
||||
if kind == "boar":
|
||||
s = "Boar (angry!)" if _aggro > 0.0 else "Boar (leaves you alone unless you hit it)"
|
||||
var held: Dictionary = ItemDB.get_item(p.held_item_id())
|
||||
if not Actions.melee_stats(p).armed and not held.has("bow"):
|
||||
s += " (you need a weapon: club, spear, bow or axe)"
|
||||
return s
|
||||
|
||||
func interact(p: Node) -> void:
|
||||
if is_dead():
|
||||
Actions.butcher(p, self)
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
step(delta)
|
||||
|
||||
func step(dt: float) -> void:
|
||||
if _flash > 0.0:
|
||||
_flash -= dt
|
||||
if _flash <= 0.0:
|
||||
_set_material(_mat)
|
||||
if is_dead():
|
||||
carcass_left -= dt
|
||||
if carcass_left <= 0.0:
|
||||
queue_free()
|
||||
return
|
||||
_attack_cd = maxf(_attack_cd - dt, 0.0)
|
||||
_aggro = maxf(_aggro - dt, 0.0)
|
||||
_think(dt)
|
||||
_move(dt)
|
||||
_animate(dt)
|
||||
|
||||
## Whether the player is close enough to be noticed. Sprinting, or moving
|
||||
## fast while swimming or jumping, is loud.
|
||||
func _notices_player(d: float) -> bool:
|
||||
if def.notice <= 0.0:
|
||||
return false
|
||||
var loud: bool = player.velocity.length() > 6.0
|
||||
return d < (def.notice_loud if loud else def.notice)
|
||||
|
||||
func _think(dt: float) -> void:
|
||||
var pp: Vector3 = player.global_position
|
||||
var d := global_position.distance_to(pp)
|
||||
_state_t -= dt
|
||||
match state:
|
||||
"idle", "wander":
|
||||
if _notices_player(d):
|
||||
_start_flee(pp)
|
||||
elif _state_t <= 0.0:
|
||||
if state == "idle":
|
||||
state = "wander"
|
||||
target_yaw = yaw + rng.randf_range(-1.6, 1.6)
|
||||
_state_t = rng.randf_range(3.0, 8.0)
|
||||
else:
|
||||
state = "idle"
|
||||
_state_t = rng.randf_range(2.0, 6.0)
|
||||
"flee":
|
||||
if _notices_player(d):
|
||||
_threat = pp
|
||||
var away := global_position - _threat
|
||||
target_yaw = atan2(-away.x, -away.z) # heading along `away` (forward is -Z)
|
||||
if kind == "rabbit":
|
||||
_zig -= dt
|
||||
if _zig <= 0.0:
|
||||
_zig = rng.randf_range(0.3, 0.7)
|
||||
target_yaw += rng.randf_range(-0.9, 0.9)
|
||||
if d > def.calm and _state_t <= 0.0:
|
||||
state = "idle"
|
||||
_state_t = rng.randf_range(2.0, 5.0)
|
||||
"charge":
|
||||
var to := pp - global_position
|
||||
target_yaw = atan2(-to.x, -to.z)
|
||||
if _aggro <= 0.0 or d > 45.0:
|
||||
state = "wander"
|
||||
_state_t = 4.0
|
||||
elif d < def.attack_range and _attack_cd <= 0.0:
|
||||
_attack_cd = def.attack_cd
|
||||
player.survival.damage(def.damage, global_position)
|
||||
|
||||
func _start_flee(from: Vector3) -> void:
|
||||
state = "flee"
|
||||
_threat = from
|
||||
_state_t = 4.0
|
||||
|
||||
func _desired_speed() -> float:
|
||||
match state:
|
||||
"wander": return def.walk
|
||||
"flee", "charge": return def.run
|
||||
return 0.0
|
||||
|
||||
func _move(dt: float) -> void:
|
||||
var want := _desired_speed()
|
||||
speed = move_toward(speed, want, dt * (def.run * 2.5))
|
||||
yaw = rotate_toward(yaw, target_yaw, def.turn * dt)
|
||||
if speed < 0.01:
|
||||
return
|
||||
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)
|
||||
target_yaw = yaw + turn
|
||||
yaw += turn * 0.35
|
||||
speed *= 0.5
|
||||
if state == "charge":
|
||||
return
|
||||
dir = Vector3(-sin(yaw), 0, -cos(yaw))
|
||||
if _blocked(dir):
|
||||
return
|
||||
var p := global_position + dir * speed * dt
|
||||
p.y = terrain.sample_height(p.x, p.z)
|
||||
global_position = p
|
||||
rotation.y = yaw
|
||||
|
||||
## Something ahead the animal can't or won't cross.
|
||||
func _blocked(dir: Vector3) -> bool:
|
||||
var probe := global_position + dir * (1.4 + speed * 0.15)
|
||||
if not terrain.inside_boundary(probe.x, probe.z, 6.0):
|
||||
return true
|
||||
var h: float = terrain.sample_height(probe.x, probe.z)
|
||||
if h < terrain.water_level + 0.2:
|
||||
return true
|
||||
if absf(h - global_position.y) > 1.1:
|
||||
return true
|
||||
# Trees, rocks and buildings at chest height (the terrain isn't on this test).
|
||||
var from := global_position + Vector3(0, 0.45, 0)
|
||||
var q := PhysicsRayQueryParameters3D.create(from, Vector3(probe.x, h + 0.45, probe.z), 1)
|
||||
q.exclude = [get_rid(), terrain.get_node("TerrainBody").get_rid()]
|
||||
return not get_world_3d().direct_space_state.intersect_ray(q).is_empty()
|
||||
|
||||
func _animate(dt: float) -> void:
|
||||
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
|
||||
for i in range(_legs.size()):
|
||||
# Diagonal pairs move together (a trot); rabbits hop with both
|
||||
# hind legs at once.
|
||||
var off := 0.0 if i in [0, 3] else PI
|
||||
if kind == "rabbit":
|
||||
off = 0.0 if i < 2 else PI * 0.6
|
||||
_legs[i].rotation.x = sin(_phase + off) * amp
|
||||
_visual.position.y = absf(sin(_phase)) * (0.06 if kind == "rabbit" else 0.03) * k
|
||||
if state == "idle" and kind != "rabbit":
|
||||
# Grazing: head (whole body) dips a little now and then.
|
||||
_visual.rotation.x = lerpf(_visual.rotation.x, 0.08 * maxf(sin(_state_t * 1.3), 0.0), dt * 2.0)
|
||||
else:
|
||||
_visual.rotation.x = lerpf(_visual.rotation.x, 0.0, dt * 4.0)
|
||||
|
||||
# --- combat -------------------------------------------------------------------
|
||||
|
||||
## Applies a hit (Actions validates who hit what). `from` = where it came
|
||||
## from, for the reaction.
|
||||
func take_hit(damage: float, from: Vector3, embed_id: String = "") -> void:
|
||||
if is_dead():
|
||||
if not embed_id.is_empty():
|
||||
embedded[embed_id] = embedded.get(embed_id, 0) + 1
|
||||
return
|
||||
hp -= damage
|
||||
if not embed_id.is_empty():
|
||||
embedded[embed_id] = embedded.get(embed_id, 0) + 1
|
||||
_flash = 0.15
|
||||
_set_material(_hit_mat)
|
||||
_burst()
|
||||
if hp <= 0.0:
|
||||
_die(from)
|
||||
return
|
||||
if kind == "boar" and hp > def.hp * 0.3:
|
||||
state = "charge"
|
||||
_aggro = def.aggro
|
||||
_attack_cd = 0.4
|
||||
else:
|
||||
_start_flee(from)
|
||||
_state_t = 6.0
|
||||
|
||||
func _die(from: Vector3) -> void:
|
||||
state = "dead"
|
||||
speed = 0.0
|
||||
hp = 0.0
|
||||
_flash = 0.0
|
||||
_set_material(_mat)
|
||||
remove_from_group("creature")
|
||||
add_to_group("carcass")
|
||||
collision_layer = 2
|
||||
for l in _legs:
|
||||
l.rotation.x = 0.4
|
||||
# Topple onto its side, away from the blow.
|
||||
var side := 1.0 if (global_position - from).dot(global_basis.x) > 0.0 else -1.0
|
||||
if is_inside_tree() and DisplayServer.get_name() != "headless":
|
||||
var tw := create_tween()
|
||||
tw.tween_property(_visual, "rotation:z", -side * PI * 0.5, 0.45).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
|
||||
else:
|
||||
_visual.rotation.z = -side * PI * 0.5
|
||||
died.emit(self)
|
||||
|
||||
## What butchering gives (rolled once).
|
||||
func butcher_drops() -> Dictionary:
|
||||
var out := {}
|
||||
for id in def.drops:
|
||||
var r: Array = def.drops[id]
|
||||
out[id] = rng.randi_range(int(r[0]), int(r[1]))
|
||||
for id in embedded:
|
||||
out[id] = out.get(id, 0) + int(embedded[id])
|
||||
return out
|
||||
|
||||
func _set_material(m: Material) -> void:
|
||||
for c in _visual.get_children():
|
||||
if c is MeshInstance3D:
|
||||
c.material_override = m
|
||||
for g in c.get_children():
|
||||
if g is MeshInstance3D:
|
||||
g.material_override = m
|
||||
|
||||
func _burst() -> void:
|
||||
if not is_inside_tree() or DisplayServer.get_name() == "headless":
|
||||
return
|
||||
var p := CPUParticles3D.new()
|
||||
var box := BoxMesh.new()
|
||||
box.size = Vector3.ONE * 0.05
|
||||
var m := StandardMaterial3D.new()
|
||||
m.albedo_color = Color(0.55, 0.08, 0.06)
|
||||
box.material = m
|
||||
p.mesh = box
|
||||
p.amount = 12
|
||||
p.one_shot = true
|
||||
p.explosiveness = 0.95
|
||||
p.lifetime = 0.6
|
||||
p.spread = 60.0
|
||||
p.initial_velocity_min = 1.5
|
||||
p.initial_velocity_max = 3.0
|
||||
p.gravity = Vector3(0, -10, 0)
|
||||
world.add_child(p)
|
||||
p.global_position = global_position + Vector3(0, Meshes.build(kind).height * 0.6, 0)
|
||||
p.emitting = true
|
||||
p.finished.connect(p.queue_free)
|
||||
98
scripts/creatures/creature_meshes.gd
Normal file
98
scripts/creatures/creature_meshes.gd
Normal file
@@ -0,0 +1,98 @@
|
||||
extends RefCounted
|
||||
## Procedural low-poly animals, built from the same helpers as the trees and
|
||||
## items (flat-shaded, vertex colours, no assets).
|
||||
##
|
||||
## Each kind gives a static "body" mesh (torso, head, ears, tail) facing -Z,
|
||||
## one "leg" mesh hanging from the origin down -Y, and the four hip points
|
||||
## the legs pivot on. creature.gd animates the legs.
|
||||
|
||||
const P = preload("res://scripts/world/proc_mesh.gd")
|
||||
|
||||
static var _cache := {}
|
||||
|
||||
static func build(kind: String) -> Dictionary:
|
||||
if _cache.has(kind):
|
||||
return _cache[kind]
|
||||
var d: Dictionary
|
||||
match kind:
|
||||
"rabbit": d = _rabbit()
|
||||
"deer": d = _deer()
|
||||
_: d = _boar()
|
||||
_cache[kind] = d
|
||||
return d
|
||||
|
||||
static func _rng(seed: int) -> RandomNumberGenerator:
|
||||
var r := RandomNumberGenerator.new()
|
||||
r.seed = seed
|
||||
return r
|
||||
|
||||
static func _noise(seed: int, f: float = 1.6) -> FastNoiseLite:
|
||||
var n := FastNoiseLite.new()
|
||||
n.seed = seed
|
||||
n.frequency = f
|
||||
return n
|
||||
|
||||
static func _leg(len: float, r0: float, r1: float, col: Color, hoof: Color, seed: int) -> ArrayMesh:
|
||||
var st := P._begin()
|
||||
var r := _rng(seed)
|
||||
P._cylinder(st, Vector3(0, 0, 0), Vector3(0, -len * 0.55, 0.02), r0, r0 * 0.75, 5, col, r, 0.0)
|
||||
P._cylinder(st, Vector3(0, -len * 0.55, 0.02), Vector3(0, -len, 0), r0 * 0.75, r1, 5, col, r, 0.0, hoof)
|
||||
return P._commit(st)
|
||||
|
||||
static func _rabbit() -> Dictionary:
|
||||
var fur := Color(0.55, 0.46, 0.36)
|
||||
var st := P._begin()
|
||||
var r := _rng(201)
|
||||
var n := _noise(201)
|
||||
P._blob(st, Vector3(0, 0.2, 0.03), Vector3(0.13, 0.13, 0.2), n, 0.15, fur, r, 1)
|
||||
P._blob(st, Vector3(0, 0.31, -0.17), Vector3(0.085, 0.085, 0.1), n, 0.12, fur, r, 1)
|
||||
for s in [-1.0, 1.0]:
|
||||
P._cylinder(st, Vector3(s * 0.035, 0.38, -0.15), Vector3(s * 0.06, 0.56, -0.1), 0.025, 0.018, 4, P._mul(fur, 1.1), r, -1.0, Color(0.85, 0.65, 0.6))
|
||||
P._blob(st, Vector3(s * 0.05, 0.33, -0.25), Vector3.ONE * 0.012, n, 0.0, Color(0.05, 0.04, 0.04), r, 0)
|
||||
P._blob(st, Vector3(0, 0.25, 0.23), Vector3.ONE * 0.05, n, 0.2, Color(0.93, 0.92, 0.88), r, 0)
|
||||
return {"body": P._commit(st), "leg": _leg(0.12, 0.03, 0.025, fur, P._mul(fur, 0.8), 202),
|
||||
"hips": [Vector3(-0.07, 0.12, -0.1), Vector3(0.07, 0.12, -0.1), Vector3(-0.08, 0.14, 0.12), Vector3(0.08, 0.14, 0.12)],
|
||||
"radius": 0.18, "height": 0.4}
|
||||
|
||||
static func _deer() -> Dictionary:
|
||||
var coat := Color(0.62, 0.42, 0.25)
|
||||
var belly := Color(0.85, 0.76, 0.62)
|
||||
var dark := Color(0.2, 0.15, 0.12)
|
||||
var st := P._begin()
|
||||
var r := _rng(211)
|
||||
var n := _noise(211, 1.2)
|
||||
P._blob(st, Vector3(0, 1.0, 0), Vector3(0.26, 0.3, 0.62), n, 0.12, coat, r, 1)
|
||||
P._blob(st, Vector3(0, 0.86, 0.05), Vector3(0.2, 0.14, 0.45), n, 0.1, belly, r, 1)
|
||||
P._cylinder(st, Vector3(0, 1.1, -0.45), Vector3(0, 1.55, -0.68), 0.13, 0.09, 6, coat, r)
|
||||
P._blob(st, Vector3(0, 1.6, -0.78), Vector3(0.11, 0.12, 0.2), n, 0.1, coat, r, 1)
|
||||
P._blob(st, Vector3(0, 1.55, -0.96), Vector3(0.05, 0.05, 0.05), n, 0.0, dark, r, 0)
|
||||
for s in [-1.0, 1.0]:
|
||||
P._cylinder(st, Vector3(s * 0.07, 1.7, -0.72), Vector3(s * 0.2, 1.82, -0.66), 0.04, 0.015, 4, coat, r)
|
||||
P._blob(st, Vector3(s * 0.08, 1.65, -0.86), Vector3.ONE * 0.022, n, 0.0, dark, r, 0)
|
||||
# Small antlers.
|
||||
P._cylinder(st, Vector3(s * 0.04, 1.72, -0.76), Vector3(s * 0.12, 2.0, -0.7), 0.018, 0.01, 4, Color(0.8, 0.74, 0.6), r)
|
||||
P._cylinder(st, Vector3(s * 0.09, 1.88, -0.72), Vector3(s * 0.08, 2.0, -0.86), 0.012, 0.006, 3, Color(0.8, 0.74, 0.6), r)
|
||||
P._blob(st, Vector3(0, 1.12, 0.62), Vector3(0.06, 0.09, 0.05), n, 0.1, belly, r, 0)
|
||||
return {"body": P._commit(st), "leg": _leg(0.88, 0.055, 0.035, coat, dark, 212),
|
||||
"hips": [Vector3(-0.14, 0.9, -0.42), Vector3(0.14, 0.9, -0.42), Vector3(-0.14, 0.92, 0.42), Vector3(0.14, 0.92, 0.42)],
|
||||
"radius": 0.4, "height": 1.7}
|
||||
|
||||
static func _boar() -> Dictionary:
|
||||
var bristle := Color(0.33, 0.25, 0.2)
|
||||
var snout := Color(0.55, 0.4, 0.36)
|
||||
var tusk := Color(0.92, 0.9, 0.82)
|
||||
var st := P._begin()
|
||||
var r := _rng(221)
|
||||
var n := _noise(221, 1.4)
|
||||
P._blob(st, Vector3(0, 0.62, 0.02), Vector3(0.32, 0.34, 0.6), n, 0.18, bristle, r, 1)
|
||||
P._blob(st, Vector3(0, 0.8, -0.2), Vector3(0.18, 0.15, 0.3), n, 0.3, P._mul(bristle, 0.75), r, 1) # mane
|
||||
P._blob(st, Vector3(0, 0.6, -0.62), Vector3(0.2, 0.2, 0.26), n, 0.15, bristle, r, 1)
|
||||
P._cylinder(st, Vector3(0, 0.55, -0.8), Vector3(0, 0.5, -0.98), 0.1, 0.08, 6, snout, r, -1.0, P._mul(snout, 0.8))
|
||||
for s in [-1.0, 1.0]:
|
||||
P._cylinder(st, Vector3(s * 0.08, 0.48, -0.88), Vector3(s * 0.12, 0.62, -0.96), 0.022, 0.006, 4, tusk, r)
|
||||
P._cylinder(st, Vector3(s * 0.1, 0.76, -0.62), Vector3(s * 0.17, 0.9, -0.55), 0.05, 0.015, 4, bristle, r)
|
||||
P._blob(st, Vector3(s * 0.11, 0.68, -0.8), Vector3.ONE * 0.02, n, 0.0, Color(0.05, 0.03, 0.03), r, 0)
|
||||
P._cylinder(st, Vector3(0, 0.7, 0.58), Vector3(0, 0.55, 0.72), 0.02, 0.012, 3, bristle, r)
|
||||
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}
|
||||
87
scripts/creatures/projectile.gd
Normal file
87
scripts/creatures/projectile.gd
Normal file
@@ -0,0 +1,87 @@
|
||||
extends Node3D
|
||||
## A flying arrow or thrown spear.
|
||||
##
|
||||
## Each physics step it moves along its velocity (with gravity) and ray-casts
|
||||
## the segment it covered, so fast arrows can't skip through a thin target.
|
||||
## - Hits an animal: damage scaled by speed. An arrow sticks in the animal and
|
||||
## comes back when you butcher it. A spear drops at the animal's feet.
|
||||
## - Hits anything else: it sticks there and becomes a pickup.
|
||||
## - After 8 s in the air (or below the world) it drops where it is.
|
||||
|
||||
var item_id := "arrow"
|
||||
var damage := 10.0
|
||||
var velocity := Vector3.ZERO
|
||||
var gravity := 9.8
|
||||
var shooter: Node3D
|
||||
var world: Node3D
|
||||
var _life := 8.0
|
||||
var _start_speed := 1.0
|
||||
|
||||
func setup(id: String, w: Node3D, from: Vector3, vel: Vector3, dmg: float, who: Node3D) -> void:
|
||||
item_id = id
|
||||
world = w
|
||||
velocity = vel
|
||||
damage = dmg
|
||||
shooter = who
|
||||
_start_speed = maxf(vel.length(), 0.1)
|
||||
gravity = 9.8 if id == "arrow" else 12.0
|
||||
var mi := MeshInstance3D.new()
|
||||
mi.mesh = ItemDB.mesh_for(id)
|
||||
mi.material_override = ItemDB.item_material()
|
||||
if id == "spear":
|
||||
# The spear mesh runs along +Y; turn it to fly point-first along -Z.
|
||||
mi.transform = Transform3D(Basis(Vector3.RIGHT, -PI * 0.5), Vector3(0, 0, 0.35))
|
||||
add_child(mi)
|
||||
position = from
|
||||
_orient()
|
||||
|
||||
func _orient() -> void:
|
||||
if velocity.length() > 0.01 and is_inside_tree():
|
||||
look_at(global_position + velocity, Vector3.UP if absf(velocity.normalized().y) < 0.99 else Vector3.RIGHT)
|
||||
|
||||
func _ready() -> void:
|
||||
_orient()
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
step(delta)
|
||||
|
||||
func step(dt: float) -> void:
|
||||
_life -= dt
|
||||
var from := global_position
|
||||
velocity.y -= gravity * dt
|
||||
var to := from + velocity * dt
|
||||
var ex: Array[RID] = []
|
||||
if shooter != null:
|
||||
ex.append(shooter.get_rid())
|
||||
var q := PhysicsRayQueryParameters3D.create(from, to, 1 | 8, ex)
|
||||
var hit := get_world_3d().direct_space_state.intersect_ray(q)
|
||||
if not hit.is_empty():
|
||||
_on_hit(hit)
|
||||
return
|
||||
global_position = to
|
||||
_orient()
|
||||
if _life <= 0.0 or to.y < world.terrain.min_height - 20.0:
|
||||
_drop(Transform3D(Basis(), Vector3(to.x, world.terrain.sample_height(to.x, to.z), to.z)))
|
||||
|
||||
func _on_hit(hit: Dictionary) -> void:
|
||||
var c: Object = hit.collider
|
||||
var dir := velocity.normalized()
|
||||
if c != null and (c as Node).is_in_group("creature"):
|
||||
var k := clampf(velocity.length() / _start_speed, 0.4, 1.0)
|
||||
Actions.projectile_hit(shooter, c, damage * k, global_position, item_id if item_id == "arrow" else "")
|
||||
if item_id == "spear":
|
||||
var at: Vector3 = (c as Node3D).global_position + Vector3(0, 0.05, 0)
|
||||
_drop(Transform3D(Basis(Vector3.UP, randf() * TAU) * Basis(Vector3.FORWARD, PI * 0.5), at))
|
||||
else:
|
||||
queue_free()
|
||||
return
|
||||
# Stick into whatever it hit, point first, slightly buried.
|
||||
var at: Vector3 = hit.position - dir * (0.12 if item_id == "arrow" else 0.25)
|
||||
var b := Basis.looking_at(dir, Vector3.UP if absf(dir.y) < 0.99 else Vector3.RIGHT)
|
||||
if item_id == "spear":
|
||||
b = b * Basis(Vector3.RIGHT, -PI * 0.5) # mesh +Y along the flight
|
||||
_drop(Transform3D(b, at))
|
||||
|
||||
func _drop(xf: Transform3D) -> void:
|
||||
world.drop_item(item_id, 1, xf)
|
||||
queue_free()
|
||||
107
scripts/creatures/spawner.gd
Normal file
107
scripts/creatures/spawner.gd
Normal file
@@ -0,0 +1,107 @@
|
||||
extends Node3D
|
||||
## Keeps a few animals around the player, within a budget per kind.
|
||||
##
|
||||
## Animals spawn in a ring 30-70 m out, out of the way, and despawn once
|
||||
## they're more than 100 m away. Each kind has its own habitat: rabbits in
|
||||
## meadows, deer at forest edges, boars deep in the woods. Animals aren't
|
||||
## saved: like Valheim, the wildlife simply carries on around you, and a
|
||||
## loaded world repopulates within a few seconds.
|
||||
|
||||
const CreatureScript = preload("res://scripts/creatures/creature.gd")
|
||||
|
||||
const BUDGET := {"rabbit": 6, "deer": 4, "boar": 3}
|
||||
const RING_MIN := 30.0
|
||||
const RING_MAX := 70.0
|
||||
const DESPAWN := 100.0
|
||||
const RESPAWN_DELAY := 25.0 ## per kind, after one dies or wanders off
|
||||
const CHECK_EVERY := 2.0
|
||||
|
||||
var world: Node3D
|
||||
var terrain: Node3D
|
||||
var props: Node3D
|
||||
var enabled := true
|
||||
var _rng := RandomNumberGenerator.new()
|
||||
var _check := 0.0
|
||||
var _cooldown := {"rabbit": 0.0, "deer": 0.0, "boar": 0.0}
|
||||
var _serial := 0
|
||||
|
||||
func setup(w: Node3D) -> void:
|
||||
world = w
|
||||
terrain = w.terrain
|
||||
props = w.props
|
||||
_rng.seed = terrain.noise_seed + 901
|
||||
|
||||
func alive(kind: String = "") -> Array:
|
||||
var out := []
|
||||
for c in get_children():
|
||||
if c.is_in_group("creature") and (kind.is_empty() or c.kind == kind):
|
||||
out.append(c)
|
||||
return out
|
||||
|
||||
## Fills the budget straight away (world start), closer in than usual so
|
||||
## there's something to see, but not on top of the player.
|
||||
func populate() -> void:
|
||||
for kind in BUDGET:
|
||||
for i in range(BUDGET[kind]):
|
||||
_spawn_near(kind, 22.0, RING_MAX)
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not enabled:
|
||||
return
|
||||
for k in _cooldown:
|
||||
_cooldown[k] = maxf(_cooldown[k] - delta, 0.0)
|
||||
_check -= delta
|
||||
if _check > 0.0:
|
||||
return
|
||||
_check = CHECK_EVERY
|
||||
var pp: Vector3 = world.player.global_position
|
||||
for c in get_children():
|
||||
if c.global_position.distance_to(pp) > DESPAWN:
|
||||
if c.is_in_group("creature"):
|
||||
_cooldown[c.kind] = RESPAWN_DELAY * 0.3
|
||||
c.queue_free()
|
||||
for kind in BUDGET:
|
||||
if _cooldown[kind] > 0.0:
|
||||
continue
|
||||
if alive(kind).size() < BUDGET[kind]:
|
||||
if _spawn_near(kind, RING_MIN, RING_MAX) != null:
|
||||
_cooldown[kind] = RESPAWN_DELAY * 0.25
|
||||
|
||||
func _on_died(c: Node) -> void:
|
||||
_cooldown[c.kind] = RESPAWN_DELAY
|
||||
|
||||
## Habitat preference, 0..1, at a spot (forest density from the scenery).
|
||||
func _habitat(kind: String, x: float, z: float) -> float:
|
||||
var f: float = props._forest(x, z)
|
||||
match kind:
|
||||
"rabbit": return 1.0 - smoothstep(0.2, 0.6, f)
|
||||
"deer": return 1.0 - absf(f - 0.45) * 1.6
|
||||
_: return smoothstep(0.35, 0.75, f)
|
||||
|
||||
func _spawn_near(kind: String, rmin: float, rmax: float) -> Node3D:
|
||||
var pp: Vector3 = world.player.global_position
|
||||
for _try in range(40):
|
||||
var a := _rng.randf() * TAU
|
||||
var r := _rng.randf_range(rmin, rmax)
|
||||
var x := pp.x + cos(a) * r
|
||||
var z := pp.z + sin(a) * r
|
||||
if not terrain.inside_boundary(x, z, 10.0):
|
||||
continue
|
||||
var h: float = terrain.sample_height(x, z)
|
||||
if h < terrain.water_level + 0.6 or h > terrain.snow_height - 4.0:
|
||||
continue
|
||||
if terrain.sample_normal(x, z).y < 0.85:
|
||||
continue
|
||||
if _rng.randf() > 0.15 + _habitat(kind, x, z) * 0.85:
|
||||
continue
|
||||
return spawn(kind, Vector3(x, h, z))
|
||||
return null
|
||||
|
||||
func spawn(kind: String, pos: Vector3) -> Node3D:
|
||||
var c: AnimatableBody3D = CreatureScript.new()
|
||||
_serial += 1
|
||||
c.setup(kind, world, pos, _rng.randi() + _serial)
|
||||
add_child(c)
|
||||
c.global_position = Vector3(pos.x, terrain.sample_height(pos.x, pos.z), pos.z)
|
||||
c.died.connect(_on_died)
|
||||
return c
|
||||
Reference in New Issue
Block a user