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. ## ## In a land with gulls (the Cove, 0.6.0: its worldlet's "wildlife"), food ## left on the sand draws a gull by day instead of a crow (gull.gd), and so ## does fish on a smoking rack that nobody's watching: a gull snatches one ## and flies off with it. ## ## 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 GullScript = preload("res://scripts/creatures/gull.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 gulls := false ## this land has gulls (the Cove) 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 gulls = w.has_wildlife("gulls") 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) if gulls and not night(): for r in racks(): if not gull_still_there(r): continue var k: int = r.get_instance_id() seen[k] = true if watched(r.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(r) and alive().size() < MAX_THIEVES: send(r) 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 (a ## gull on the sand, or at a smoking rack, where there are gulls). func send(food: Node3D) -> Node3D: var fox := night() var gull := not fox and gulls and (food.is_in_group("processor") or on_sand(food.global_position)) var fp: Vector3 = spot(food) 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 (GullScript if gull else CrowScript)).new() t.setup("fox" if fox else ("gull" if gull 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() # --- gulls (the Cove) ---------------------------------------------------------------- func on_sand(pos: Vector3) -> bool: return world.terrain.on_sand(pos.x, pos.z) static func is_fish(id: String) -> bool: return Dictionary(ItemDB.get_item(id).get("food", {})).get("diet", {}).has("fish") ## This land's smoking racks. func racks() -> Array: return get_tree().get_nodes_in_group("processor").filter(func(r): return is_instance_valid(r) and not r.is_queued_for_deletion() \ and String(r.item_id) == "smoking_rack" and world.is_ancestor_of(r)) ## Fish on a rack: the first id there is, or "". func _rack_fish(r: Node3D) -> String: for st in r.inventory.slots: if st != null and is_fish(String(st.id)): return String(st.id) return "" ## Whether a gull's target is still worth it: food on the ground, or a rack ## with fish on it. func gull_still_there(t: Node3D) -> bool: if t == null or not is_instance_valid(t) or t.is_queued_for_deletion(): return false if t.is_in_group("processor"): return not _rack_fish(t).is_empty() return still_there(t) ## Where a thief lands for `t`: on food, or on the ground beside a rack. func spot(t: Node3D) -> Vector3: if t.is_in_group("processor"): var p: Vector3 = t.global_position + t.global_basis.z * 0.8 return Vector3(p.x, world.terrain.sample_height(p.x, p.z), p.z) return t.global_position func gull_landed(_gull: Node, t: Node3D) -> void: if t.is_in_group("processor"): _tell(t.global_position, "A gull's at the fish on your smoking rack!") else: _tell(t.global_position, "A gull's landed by your %s!" % ItemDB.item_name(t.item_id).to_lower()) ## A gull takes one and flies off with it. Returns what it's carrying. func snatch(gull: Node, t: Node3D) -> Dictionary: if not gull_still_there(t): return {} var c := {} if t.is_in_group("processor"): var id := _rack_fish(t) t.inventory.remove(id, 1) c = {"id": id, "count": 1, "extra": {}} else: c = {"id": String(t.item_id), "count": 1, "extra": t.extra.duplicate()} t.amount = int(t.amount) - 1 if int(t.amount) <= 0: world.take_dropped(t) _tell(t.global_position, "A gull has snatched your %s!" % ItemDB.item_name(String(c.id)).to_lower()) stolen.emit(gull, String(c.id), 1) return c