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]}}, # 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 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 _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 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) 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 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 = {"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) 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 puppet: _puppet_step(dt) return 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) # Who it's reacting to: the player (or on a server, the nearest one). var t := _pick_target() if t != null: player = t _think(dt) elif state in ["flee", "charge"]: state = "idle" # nobody about _move(dt) _animate(dt) func _pick_target() -> Node3D: return world.nearest_player(global_position) # --- on a client playing online: the server's animal, shown here ------------ ## A puppet only shows what the server's copy is doing (creature_net.gd): ## no thinking, no damage dealt. It can still be hit (that's sent to the ## server) and butchered. var puppet := false var _net_pos := Vector3.ZERO var _net_yaw := 0.0 func net_update(pos: Vector3, y: float, st: String, spd: float, new_hp: float) -> void: if not puppet: return _net_pos = pos _net_yaw = y speed = spd if new_hp < hp - 0.01 and st != "dead": _flash = 0.15 _set_material(_hit_mat) _burst() hp = new_hp if st == "dead" and state != "dead": _burst() _die(global_position + global_basis.z) return if state != "dead": state = st func _puppet_step(dt: float) -> void: if is_dead(): return var k := 1.0 - exp(-12.0 * dt) var p := global_position.lerp(_net_pos, k) if p.distance_to(_net_pos) > 6.0: p = _net_pos # (a jump: a wolf slipping home to the den) global_position = p yaw = lerp_angle(yaw, _net_yaw, k) rotation.y = yaw _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 * world.difficulty_mult("damage"), global_position, "a " + String(def.name).to_lower()) 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 _detour_t = maxf(_detour_t - dt, 0.0) var dir := Vector3(-sin(yaw), 0, -cos(yaw)) if _blocked(dir): # 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 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: 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 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 puppet: return # (online the server's copy takes the hit) 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 _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 _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 if puppet: _visual.rotation.z = -PI * 0.5 return # 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 and not c.has_meta("keep_mat"): c.material_override = m for g in c.get_children(): if g is MeshInstance3D and not g.has_meta("keep_mat"): 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)