P3 thieves and barrow: crows and foxes steal food left out; the barrow with its guards, coffin loot and key pieces; blunt club vs bone (protocol 10)

This commit is contained in:
Paul Bailey
2026-11-05 09:23:31 +00:00
parent 96aac29606
commit 9528f91e47
20 changed files with 1695 additions and 21 deletions

View File

@@ -61,6 +61,8 @@ func _find_home() -> bool:
continue
if Vector2(c.x - spawn.x, c.z - spawn.z).length() < 60.0 or Vector2(c.x - den_at.x, c.z - den_at.z).length() < 60.0:
continue
if world.barrow != null and world.barrow.found and Vector2(c.x - world.barrow.position.x, c.z - world.barrow.position.z).length() < 40.0:
continue
if terrain.sample_height(c.x, c.z) < terrain.water_level + 1.0:
continue
var near := 0
@@ -202,7 +204,7 @@ func raid(b: Node3D, target: Node3D) -> int:
what = "A bear has broken into your chest and eaten the food!"
else:
eaten = int(target.amount)
target.collected()
world.take_dropped(target)
what = "A bear is eating the food you left out!"
for p in world.players():
if p.global_position.distance_to(target.global_position) < 80.0:

View File

@@ -46,6 +46,20 @@ const KINDS := {
"notice": 0.0, "notice_loud": 0.0, "calm": 40.0,
"damage": 16.0, "attack_range": 2.1, "attack_cd": 1.4, "door_damage": 22.0,
"drops": {"raw_meat": [2, 3], "alpha_pelt": [1, 1], "wolf_fang": [1, 2]}},
# Thieves (thieves.gd): crows by day and foxes at dusk and night come for
# food left lying on the ground.
"crow": {"name": "Crow", "hp": 6.0, "walk": 1.0, "run": 9.0, "turn": 8.0,
"notice": 0.0, "notice_loud": 0.0, "calm": 30.0,
"drops": {"feather": [1, 2]}},
"fox": {"name": "Fox", "hp": 25.0, "walk": 1.5, "run": 7.6, "turn": 7.0,
"notice": 0.0, "notice_loud": 0.0, "calm": 30.0,
"drops": {"raw_meat": [1, 1], "hide": [1, 1]}},
# The barrow's skeletal guards (guard.gd, barrow.gd). Slow (you can walk
# away from one) but they hit hard; bone, so blunt weapons do +50%.
"guard": {"name": "Barrow Guard", "hp": 80.0, "walk": 1.4, "run": 3.4, "turn": 4.0,
"notice": 0.0, "notice_loud": 0.0, "calm": 30.0,
"damage": 20.0, "attack_range": 2.0, "attack_cd": 2.0, "bone": true,
"drops": {"iron_nugget": [1, 2]}},
# One bear per island, in the deep forest by the rim (bear.gd, bear_range.gd).
# Slower than you sprinting, but it hits like a falling tree.
"bear": {"name": "Bear", "hp": 220.0, "walk": 1.3, "run": 7.0, "turn": 3.5,
@@ -118,10 +132,10 @@ func setup(k: String, w: Node3D, pos: Vector3, seed: int) -> void:
var eyes := MeshInstance3D.new()
eyes.mesh = parts.eyes
var em := StandardMaterial3D.new()
var red := k == "alpha"
em.albedo_color = Color(1.0, 0.3, 0.2) if red else Color(1.0, 0.85, 0.4)
var eye: Color = {"alpha": Color(1.0, 0.18, 0.1), "guard": Color(0.45, 0.8, 1.0)}.get(k, Color(1.0, 0.75, 0.3))
em.albedo_color = eye.lerp(Color.WHITE, 0.2)
em.emission_enabled = true
em.emission = Color(1.0, 0.18, 0.1) if red else Color(1.0, 0.75, 0.3)
em.emission = eye
em.emission_energy_multiplier = 2.5
eyes.material_override = em
eyes.set_meta("keep_mat", true)
@@ -141,9 +155,9 @@ func setup(k: String, w: Node3D, pos: Vector3, seed: int) -> void:
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":
if k != "rabbit" and k != "guard": # (rabbits are round, guards stand upright)
# Long bodies: lay the capsule along the spine.
cap.height = {"deer": 1.5, "boar": 1.4, "wolf": 1.25, "alpha": 1.65, "bear": 2.0}.get(k, 1.4)
cap.height = {"deer": 1.5, "boar": 1.4, "wolf": 1.25, "alpha": 1.65, "bear": 2.0, "fox": 0.8, "crow": 0.3}.get(k, 1.4)
cs.rotation.x = PI * 0.5
cs.position = Vector3(0, parts.height * 0.55, 0)
add_child(cs)

View File

@@ -20,6 +20,9 @@ static func build(kind: String) -> Dictionary:
"wolf": d = _wolf()
"alpha": d = _wolf(true)
"bear": d = _bear()
"fox": d = _scaled(_wolf_parts(Color(0.78, 0.4, 0.15), Color(0.92, 0.88, 0.8), 281), 0.6)
"crow": d = _crow()
"guard": d = _guard()
_: d = _boar()
_cache[kind] = d
return d
@@ -178,3 +181,72 @@ static func _bear() -> Dictionary:
return {"body": P._commit(st), "eyes": P._commit(est), "leg": _leg(0.72, 0.13, 0.11, coat, dark, 262),
"hips": [Vector3(-0.28, 0.72, -0.5), Vector3(0.28, 0.72, -0.5), Vector3(-0.28, 0.74, 0.6), Vector3(0.28, 0.74, 0.6)],
"radius": 0.62, "height": 1.6}
## Crow: a small black bird facing -Z. The two "legs" are its wings
## (crow.gd swings them out sideways and flaps them).
static func _crow() -> Dictionary:
var black := Color(0.08, 0.08, 0.1)
var sheen := Color(0.14, 0.15, 0.2)
var beak := Color(0.2, 0.18, 0.15)
var st := P._begin()
var r := _rng(291)
var n := _noise(291, 2.0)
P._blob(st, Vector3(0, 0.16, 0), Vector3(0.07, 0.07, 0.14), n, 0.1, black, r, 1)
P._blob(st, Vector3(0, 0.22, -0.13), Vector3(0.05, 0.05, 0.055), n, 0.1, sheen, r, 1)
P._cylinder(st, Vector3(0, 0.215, -0.17), Vector3(0, 0.2, -0.24), 0.016, 0.002, 4, beak, r)
P._cylinder(st, Vector3(0, 0.17, 0.12), Vector3(0, 0.15, 0.26), 0.03, 0.05, 4, black, r)
for s in [-1.0, 1.0]:
P._cylinder(st, Vector3(s * 0.025, 0.1, 0.0), Vector3(s * 0.03, 0.0, 0.02), 0.006, 0.005, 3, beak, r)
for s in [-1.0, 1.0]: # (dull eyes: no glowing "eyes" mesh for a crow)
P._blob(st, Vector3(s * 0.035, 0.235, -0.16), Vector3.ONE * 0.008, n, 0.0, Color(0.3, 0.25, 0.2), r, 0)
var wst := P._begin()
P._blob(wst, Vector3(0, -0.14, 0.02), Vector3(0.012, 0.15, 0.08), n, 0.05, sheen, r, 0)
return {"body": P._commit(st), "leg": P._commit(wst),
"hips": [Vector3(-0.06, 0.19, -0.01), Vector3(0.06, 0.19, -0.01)],
"radius": 0.12, "height": 0.3}
## Barrow guard: an upright skeleton facing -Z, in a rusted helm, an old
## sword in its right hand and a round shield on its left arm. Two legs
## (creature.gd walks them like a pair of a quadruped's).
static func _guard() -> Dictionary:
var bone := Color(0.82, 0.79, 0.68)
var old := Color(0.62, 0.58, 0.48)
var rust := Color(0.42, 0.24, 0.14)
var dark := Color(0.06, 0.06, 0.07)
var wood := Color(0.32, 0.22, 0.14)
var st := P._begin()
var r := _rng(301)
var n := _noise(301, 2.2)
P._blob(st, Vector3(0, 0.95, 0), Vector3(0.17, 0.07, 0.1), n, 0.2, old, r, 0) # pelvis
P._cylinder(st, Vector3(0, 0.97, 0.02), Vector3(0, 1.45, 0.0), 0.035, 0.03, 5, bone, r) # spine
for i in range(4): # ribs
var y := 1.18 + 0.075 * i
P._cylinder(st, Vector3(-0.15 + 0.01 * i, y, -0.02), Vector3(0.15 - 0.01 * i, y, -0.02), 0.018, 0.018, 4, bone, r)
for s in [-1.0, 1.0]:
P._cylinder(st, Vector3(s * (0.15 - 0.01 * i), y, -0.02), Vector3(s * 0.11, y - 0.02, 0.07), 0.015, 0.015, 3, old, r)
P._cylinder(st, Vector3(-0.22, 1.47, 0), Vector3(0.22, 1.47, 0), 0.025, 0.025, 4, bone, r) # shoulders
P._blob(st, Vector3(0, 1.66, -0.02), Vector3(0.11, 0.13, 0.12), n, 0.12, bone, r, 1) # skull
P._blob(st, Vector3(0, 1.56, -0.08), Vector3(0.07, 0.04, 0.06), n, 0.1, old, r, 0) # jaw
P._cylinder(st, Vector3(0, 1.69, -0.01), Vector3(0, 1.82, -0.01), 0.14, 0.06, 7, rust, r, -1.0, rust) # helm
P._cylinder(st, Vector3(0, 1.66, -0.12), Vector3(0, 1.5, -0.14), 0.018, 0.012, 4, rust, r) # nose guard
for s in [-1.0, 1.0]:
P._blob(st, Vector3(s * 0.045, 1.67, -0.115), Vector3(0.03, 0.025, 0.012), n, 0.0, dark, r, 0) # sockets
# Right arm forward with the sword, left arm with the shield.
P._cylinder(st, Vector3(0.22, 1.47, 0), Vector3(0.28, 1.2, -0.12), 0.025, 0.022, 4, bone, r)
P._cylinder(st, Vector3(0.28, 1.2, -0.12), Vector3(0.27, 1.08, -0.38), 0.022, 0.02, 4, bone, r)
P._cylinder(st, Vector3(0.27, 1.08, -0.4), Vector3(0.29, 1.5, -0.95), 0.03, 0.006, 4, rust, r) # blade
P._cylinder(st, Vector3(0.2, 1.05, -0.38), Vector3(0.34, 1.1, -0.4), 0.02, 0.02, 4, rust, r) # crossguard
P._cylinder(st, Vector3(-0.22, 1.47, 0), Vector3(-0.3, 1.2, -0.05), 0.025, 0.022, 4, bone, r)
P._cylinder(st, Vector3(-0.3, 1.2, -0.05), Vector3(-0.28, 1.05, -0.25), 0.022, 0.02, 4, bone, r)
P._cylinder(st, Vector3(-0.33, 1.12, -0.3), Vector3(-0.31, 1.12, -0.36), 0.26, 0.26, 9, wood, r, -1.0, wood) # shield
P._cylinder(st, Vector3(-0.31, 1.12, -0.36), Vector3(-0.305, 1.12, -0.4), 0.06, 0.04, 6, rust, r, -1.0, rust) # boss
var est := P._begin()
for s in [-1.0, 1.0]:
P._blob(est, Vector3(s * 0.045, 1.67, -0.125), Vector3.ONE * 0.012, n, 0.0, Color(0.5, 0.85, 1.0), r, 0)
var lst := P._begin()
P._cylinder(lst, Vector3(0, 0, 0), Vector3(0, -0.45, 0.02), 0.03, 0.025, 4, bone, r)
P._cylinder(lst, Vector3(0, -0.45, 0.02), Vector3(0, -0.9, 0), 0.025, 0.022, 4, bone, r)
P._blob(lst, Vector3(0, -0.9, -0.05), Vector3(0.04, 0.025, 0.08), n, 0.1, old, r, 0)
return {"body": P._commit(st), "eyes": P._commit(est), "leg": P._commit(lst),
"hips": [Vector3(-0.1, 0.93, 0), Vector3(0.1, 0.93, 0)],
"radius": 0.3, "height": 1.85}

115
scripts/creatures/crow.gd Normal file
View File

@@ -0,0 +1,115 @@
extends "res://scripts/creatures/creature.gd"
## A crow (docs/PROGRESSION_DESIGN.md §4): sent by thieves.gd, by day, for
## food left lying on the ground. It flies in, lands on it and pecks it away
## a bit at a time, and takes off again if anyone comes within SCARE. Then
## it flies off over the trees and is gone. A closed chest keeps food safe.
## An arrow brings it down (feathers).
const SCARE := 7.0
const PECK_EVERY := 3.0
const FLY_SPEED := 9.0
const CRUISE := 7.0 ## height above the ground while flying in
const LEAVE_HEIGHT := 14.0
var thieves: Node ## thieves.gd
var food: Node3D = null
var _goal := Vector3.ZERO
var _peck_anim := 0.0
func display_name() -> String:
return "Crow"
func interact_prompt(p: Node) -> String:
if is_dead():
return "Crow [E] Pluck"
return {"peck": "Crow (pecking at your food!)", "fly": "Crow (circling your food)"}.get(state, "Crow")
func _flying() -> bool:
return state in ["fly", "fly_off"]
func _think(dt: float) -> void:
match state:
"fly":
if not thieves.still_there(food) or thieves.watched(food.global_position, SCARE):
_leave()
return
_goal = food.global_position
var ground: float = terrain.sample_height(global_position.x, global_position.z)
if Vector2(_goal.x - global_position.x, _goal.z - global_position.z).length() < 0.35 and global_position.y - ground < 0.15:
state = "peck"
_state_t = PECK_EVERY
thieves.landed(self, food)
"peck":
if not thieves.still_there(food) or thieves.watched(global_position, SCARE):
_leave()
return
_state_t -= dt
if _state_t <= 0.0:
_state_t = PECK_EVERY
_peck_anim = 0.4
if thieves.peck(self, food): # the last of it
_leave()
"fly_off":
if thieves.out_of_sight(global_position):
thieves.gone(self)
_:
_leave()
func _leave() -> void:
state = "fly_off"
food = null
var p: Node3D = world.nearest_player(global_position)
var away := Vector3(1, 0, 0)
if p != null:
away = global_position - p.global_position
away.y = 0.0
if away.length() < 0.1:
away = Vector3(1, 0, 0)
_goal = global_position + away.normalized() * 120.0
## Straight-line flight (no steering round trees: it flies over them).
func _move(dt: float) -> void:
if not _flying():
speed = 0.0
return
var to := _goal - global_position
to.y = 0.0
var flat := to.length()
var spd := FLY_SPEED
if state == "fly" and flat < 6.0:
spd = maxf(flat * 1.5, 1.2) # glide in to land
var p := global_position
if flat > 0.02:
yaw = rotate_toward(yaw, atan2(-to.x, -to.z), 8.0 * dt)
p += to / flat * minf(spd * dt, flat)
var ground: float = terrain.sample_height(p.x, p.z)
var alt := LEAVE_HEIGHT
if state == "fly":
alt = CRUISE * clampf((flat - 0.3) / 8.0, 0.0, 1.0)
p.y = maxf(move_toward(global_position.y, maxf(ground, terrain.water_level) + alt, 5.0 * dt), ground)
global_position = p
rotation.y = yaw
speed = spd
## Wings out and flapping in the air, folded on the ground; a dip of the
## head when it pecks.
func _animate(dt: float) -> void:
var flying := _flying() or (puppet and speed > 0.5)
_phase += dt * 16.0
for l in _legs:
var s := -1.0 if l.position.x < 0.0 else 1.0
l.rotation.z = s * ((PI * 0.5 + sin(_phase) * 0.7) if flying else 0.2)
_peck_anim = maxf(_peck_anim - dt, 0.0)
if is_dead():
return
_visual.rotation.x = 0.5 * sin(_peck_anim / 0.4 * PI) if state == "peck" else 0.0
func _react(_from: Vector3) -> void:
_leave()
## Brought down out of the air: it lands where it fell.
func _die(from: Vector3) -> void:
super._die(from)
var p := global_position
p.y = terrain.sample_height(p.x, p.z)
global_position = p

95
scripts/creatures/fox.gd Normal file
View File

@@ -0,0 +1,95 @@
extends "res://scripts/creatures/creature.gd"
## A fox (docs/PROGRESSION_DESIGN.md §4): sent by thieves.gd at dusk and
## night for food left lying on the ground. It slinks up, grabs the whole
## stack in its jaws and runs off with it. Come within WARY while it's
## creeping in and it bolts empty-mouthed. Kill it while it's carrying and
## it drops what it took; let it get away and the food's gone.
const WARY := 10.0
const SNEAK_SPEED := 3.4
const GIVE_UP := 30.0 ## seconds running with it before it's gone for good
var thieves: Node
var food: Node3D = null
var carry: Dictionary = {} ## {id, count, extra} once it has the food
var _escape := Vector3.ZERO
var _goal_d := 0.0
func display_name() -> String:
return "Fox"
func interact_prompt(p: Node) -> String:
if is_dead():
return "Fox carcass [E] Butcher"
if not carry.is_empty():
return "Fox (running off with your %s!)" % ItemDB.item_name(String(carry.id)).to_lower()
return "Fox (sneaking up on your food)" if state == "sneak" else "Fox"
func _think(dt: float) -> void:
var pp: Vector3 = player.global_position
_state_t -= dt
match state:
"sneak":
if not thieves.still_there(food):
_bolt(pp)
return
if thieves.watched(global_position, WARY):
_bolt(pp)
return
var fp: Vector3 = food.global_position
_go_to(fp)
if Vector2(fp.x - global_position.x, fp.z - global_position.z).length() < 0.9:
carry = thieves.grab(self, food)
food = null
state = "carry"
_state_t = GIVE_UP
_escape = _away_point(pp, 60.0)
"carry", "flee":
_go_to(_escape)
if thieves.out_of_sight(global_position) or _state_t <= 0.0 or _goal_d < 1.5:
thieves.gone(self)
_:
_bolt(pp)
func _bolt(from: Vector3) -> void:
state = "flee" if carry.is_empty() else "carry"
food = null
_state_t = GIVE_UP
_escape = _away_point(from, 60.0)
## A point `d` away from `from`, through the fox, kept inside the valley.
func _away_point(from: Vector3, d: float) -> Vector3:
var away := global_position - from
away.y = 0.0
if away.length() < 0.1:
away = Vector3(1, 0, 0)
away = away.normalized()
for turn in [0.0, 0.6, -0.6, 1.2, -1.2, PI]:
var p: Vector3 = global_position + away.rotated(Vector3.UP, turn) * d
if terrain.inside_boundary(p.x, p.z, 8.0) and terrain.sample_height(p.x, p.z) > terrain.water_level + 0.3:
return p
return global_position + away * d * 0.3
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()
func _desired_speed() -> float:
match state:
"sneak": return 0.0 if _goal_d < 0.5 else SNEAK_SPEED
"carry", "flee": return def.run
return 0.0
func _react(from: Vector3) -> void:
_bolt(from)
## Killed with the food in its jaws: it drops it.
func _die(from: Vector3) -> void:
super._die(from)
if not carry.is_empty() and not puppet:
var at := global_position + Vector3(0, 0.3, 0)
world.drop_item(String(carry.id), int(carry.count), Transform3D(Basis(), at), carry.get("extra", {}))
carry = {}

110
scripts/creatures/guard.gd Normal file
View File

@@ -0,0 +1,110 @@
extends "res://scripts/creatures/creature.gd"
## A barrow guard (docs/PROGRESSION_DESIGN.md §4): a skeleton standing at its
## post in the barrow (barrow.gd) until something wakes it: someone inside
## the stone ring at night (or any time after day 10), someone opening the
## coffin, or a blow. Then it comes for you. It's slow, so you can walk away
## from it, but it hits hard and it isn't afraid of fire or torches. It
## won't follow you far from the barrow; it goes back to its post and stands
## there again.
var barrow: Node ## barrow.gd
var post := Vector3.ZERO
var post_yaw := 0.0
var _goal_d := 0.0
func display_name() -> String:
return def.name
func interact_prompt(p: Node) -> String:
if is_dead():
return "Bones [E] Search"
var s := "Barrow guard"
match state:
"rest": s = "Barrow guard (still... for now)"
"charge": s = "Barrow guard (coming for you!)"
"home": s = "Barrow guard (going back to its post)"
if not Actions.melee_stats(p).armed and not ItemDB.get_item(p.held_item_id()).has("bow"):
s += " (you need a weapon: a club cracks bone)"
return s
func _think(dt: float) -> void:
var pp: Vector3 = player.global_position
var d := global_position.distance_to(pp)
_state_t -= dt
match state:
"rest":
target_yaw = post_yaw
if barrow.wakes_for(player):
state = "charge"
_attack_cd = 0.8
"charge":
if player.survival.dead or barrow.center().distance_to(pp) > barrow.LEASH or not barrow.alarmed:
state = "home"
return
var to := pp - global_position
steer(atan2(-to.x, -to.z))
_goal_d = d
if d < def.attack_range and _attack_cd <= 0.0 and _can_reach(player):
_attack_cd = def.attack_cd
player.survival.damage(def.damage * world.difficulty_mult("damage"), global_position, "a barrow guard")
"home":
if barrow.wakes_for(player) and barrow.center().distance_to(pp) < barrow.LEASH:
state = "charge"
return
var to := post - global_position
to.y = 0.0
_goal_d = to.length()
if _goal_d < 0.4:
state = "rest"
else:
steer(atan2(-to.x, -to.z))
_:
state = "home"
func _desired_speed() -> float:
match state:
"charge": return 0.0 if _goal_d < def.attack_range * 0.8 else def.run
"home": return 0.0 if _goal_d < 0.4 else def.walk * 1.5
return 0.0
## Nothing solid between its sword and you.
func _can_reach(p: Node3D) -> bool:
var from := global_position + Vector3(0, 1.2, 0)
var to: Vector3 = p.global_position + Vector3(0, 1.0, 0)
var q := PhysicsRayQueryParameters3D.create(from, to, 1)
q.exclude = [get_rid(), p.get_rid(), terrain.get_node("TerrainBody").get_rid()]
return get_world_3d().direct_space_state.intersect_ray(q).is_empty()
## In the barrow, what's ahead is measured from the stone floor (the ground
## under it is lower, and a probe aimed at that would hit the floor slab).
func _blocked(dir: Vector3) -> bool:
var probe := global_position + dir * (1.4 + speed * 0.15)
var f: float = barrow.floor_y_at(probe)
if f == -INF and barrow.floor_y_at(global_position) == -INF:
return super._blocked(dir)
if not terrain.inside_boundary(probe.x, probe.z, 6.0):
return true
var h: float = f if f > -INF else terrain.sample_height(probe.x, probe.z)
if absf(h - global_position.y) > 1.1:
return true
var q := PhysicsRayQueryParameters3D.create(global_position + Vector3(0, 0.45, 0), 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()
## Inside the barrow it walks on the stone floor, not the ground under it.
func _move(dt: float) -> void:
super._move(dt)
var f: float = barrow.floor_y_at(global_position)
if f > global_position.y:
global_position.y = f
func _react(_from: Vector3) -> void:
barrow.alarm()
state = "charge"
_attack_cd = maxf(_attack_cd, 0.6)
## Standing upright, a blow knocks it to pieces (no toppling sideways).
func _die(from: Vector3) -> void:
super._die(from)
_visual.rotation = Vector3(-PI * 0.5, 0, 0)
_visual.position.y = 0.15

View File

@@ -0,0 +1,150 @@
extends Node3D
## Thieves (docs/PROGRESSION_DESIGN.md §4): crows by day and foxes at dusk
## and night, after food left lying on the ground.
##
## Food on the ground that nobody has been within UNWATCHED of for
## NOTICE_AFTER seconds draws a thief. It comes from COME_FROM away, out of
## sight of everyone. A crow pecks the stack away one at a time; a fox runs
## off with all of it. Either one gives up if you come near (crow.gd,
## fox.gd). Food in a chest is never touched (that's what chests are for);
## the bear (bear_range.gd) is another matter.
##
## On a server this runs for everyone (thieves are wildlife: creature_net.gd).
const CrowScript = preload("res://scripts/creatures/crow.gd")
const FoxScript = preload("res://scripts/creatures/fox.gd")
const CHECK_EVERY := 5.0
const UNWATCHED := 10.0
const NOTICE_AFTER := 20.0
const MAX_THIEVES := 3
const COME_FROM := 35.0
const OUT_OF_SIGHT := 45.0 ## this far from everyone, a thief that's leaving is gone
signal stolen(thief: Node, id: String, count: int)
var world: Node3D
var enabled := true
var _acc := 0.0
var _unwatched: Dictionary = {} ## pickup instance id -> seconds left alone
var _rng := RandomNumberGenerator.new()
func setup(w: Node3D) -> void:
world = w
_rng.seed = w.terrain.noise_seed + 997
func _physics_process(delta: float) -> void:
if enabled:
step(delta)
## Living thieves, coming or going.
func alive() -> Array:
return get_children().filter(func(c): return is_instance_valid(c) and c.has_method("is_dead") and not c.is_dead() and not c.is_queued_for_deletion())
static func is_food(id: String) -> bool:
return ItemDB.get_item(id).has("food")
func night() -> bool:
var h: float = world.day_night.hours()
return h >= 17.5 or h < 6.5
## Someone (alive) within `r` of `pos`.
func watched(pos: Vector3, r: float) -> bool:
for p in world.players():
if not p.survival.dead and p.global_position.distance_to(pos) < r:
return true
return false
func out_of_sight(pos: Vector3) -> bool:
return not watched(pos, OUT_OF_SIGHT)
func still_there(food: Node3D) -> bool:
return food != null and is_instance_valid(food) and not food.is_queued_for_deletion() and not food.taken and int(food.amount) > 0
func _targeted(food: Node3D) -> bool:
for t in alive():
if t.food == food:
return true
return false
func step(dt: float) -> void:
_acc += dt
if _acc < CHECK_EVERY:
return
var since := _acc
_acc = 0.0
var seen := {}
for d in world.all_dropped():
if not is_food(d.item_id):
continue
var k: int = d.get_instance_id()
seen[k] = true
if watched(d.global_position, UNWATCHED):
_unwatched.erase(k)
continue
_unwatched[k] = float(_unwatched.get(k, 0.0)) + since
if float(_unwatched[k]) >= NOTICE_AFTER and not _targeted(d) and alive().size() < MAX_THIEVES:
send(d)
for k in _unwatched.keys():
if not seen.has(k):
_unwatched.erase(k)
## Sends a thief after `food`: a fox at dusk and night, otherwise a crow.
func send(food: Node3D) -> Node3D:
var fox := night()
var fp: Vector3 = food.global_position
var from := Vector3.INF
for _i in range(12):
var a := _rng.randf() * TAU
var p := fp + Vector3(cos(a), 0, sin(a)) * COME_FROM
var ground: float = world.terrain.sample_height(p.x, p.z)
if world.terrain.inside_boundary(p.x, p.z, 6.0) and (not fox or ground > world.terrain.water_level + 0.3) \
and not watched(p, 20.0):
from = p
break
if from == Vector3.INF:
return null
var t: AnimatableBody3D = (FoxScript if fox else CrowScript).new()
t.setup("fox" if fox else "crow", world, from, _rng.randi())
t.thieves = self
t.food = food
t.state = "sneak" if fox else "fly"
add_child(t)
if not fox:
t.global_position.y = world.terrain.sample_height(from.x, from.z) + t.CRUISE
var to := fp - from
t.yaw = atan2(-to.x, -to.z)
t.rotation.y = t.yaw
return t
## Whoever left `food` there (anyone within 60 m) hears about it, once.
func _tell(food_pos: Vector3, text: String) -> void:
for p in world.players():
if p.global_position.distance_to(food_pos) < 60.0:
Actions.tell(p, text)
func landed(crow: Node, food: Node3D) -> void:
_tell(food.global_position, "A crow is pecking at your %s!" % ItemDB.item_name(food.item_id).to_lower())
## A crow takes one. True when that was the last of it.
func peck(crow: Node, food: Node3D) -> bool:
if not still_there(food):
return true
food.amount = int(food.amount) - 1
stolen.emit(crow, food.item_id, 1)
if int(food.amount) <= 0:
world.take_dropped(food)
return true
return false
## A fox takes the lot. Returns what it's carrying.
func grab(fox: Node, food: Node3D) -> Dictionary:
var c := {"id": String(food.item_id), "count": int(food.amount), "extra": food.extra.duplicate()}
_tell(food.global_position, "A fox has run off with your %s!" % ItemDB.item_name(c.id).to_lower())
world.take_dropped(food)
stolen.emit(fox, c.id, c.count)
return c
## A thief left with what it took (or empty-handed).
func gone(t: Node) -> void:
t.queue_free()