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

@@ -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()