diff --git a/data/items.json b/data/items.json index 8a3caa0..6d7175f 100644 --- a/data/items.json +++ b/data/items.json @@ -65,12 +65,13 @@ "name": "Wooden Club", "category": "weapon", "stack": 1, - "description": "Crude, heavy, effective. (Creatures arrive in M1d.)", + "description": "Crude, heavy, effective. One blow fells a rabbit.", "mesh": "club", "weapon": { "damage": 12, - "reach": 2.2, - "stamina": 8 + "reach": 2.4, + "stamina": 8, + "cooldown": 0.55 } }, { @@ -266,6 +267,113 @@ 2.1 ] } + }, + { + "id": "raw_meat", + "name": "Raw Meat", + "category": "food", + "stack": 20, + "description": "Cook it at a campfire. Eaten raw it barely helps.", + "mesh": "raw_meat", + "food": { + "hunger": 5, + "health": -4 + } + }, + { + "id": "cooked_meat", + "name": "Cooked Meat", + "category": "food", + "stack": 20, + "description": "Hearty. Leaves you Well fed: faster stamina and healing for 10 minutes.", + "mesh": "cooked_meat", + "food": { + "hunger": 34, + "health": 12, + "buff": { + "id": "well_fed", + "name": "Well fed", + "seconds": 600, + "stamina_regen": 1.3, + "health_regen": 2.0 + } + } + }, + { + "id": "hide", + "name": "Hide", + "category": "resource", + "stack": 20, + "description": "Raw hide from a deer or boar. Makes a warm cloak.", + "mesh": "hide" + }, + { + "id": "feather", + "name": "Feather", + "category": "resource", + "stack": 50, + "description": "Found in meadows. Fletching for arrows.", + "mesh": "feather" + }, + { + "id": "spear", + "name": "Wooden Spear", + "category": "weapon", + "stack": 1, + "description": "LMB thrusts (long reach). RMB throws it: pick it up again where it lands.", + "mesh": "spear", + "weapon": { + "damage": 18, + "reach": 3.1, + "stamina": 10, + "cooldown": 0.6, + "throw": { + "damage": 30, + "speed": 24.0, + "stamina": 14 + } + } + }, + { + "id": "bow", + "name": "Crude Bow", + "category": "weapon", + "stack": 1, + "description": "Hold LMB to draw, release to shoot. Uses arrows.", + "mesh": "bow", + "bow": { + "ammo": "arrow", + "draw": 0.9, + "damage": [ + 8, + 32 + ], + "speed": [ + 16.0, + 46.0 + ], + "stamina": 6 + } + }, + { + "id": "arrow", + "name": "Flint Arrow", + "category": "weapon", + "stack": 50, + "description": "Ammunition for the bow. Arrows that miss can be picked up again.", + "mesh": "arrow" + }, + { + "id": "hide_cloak", + "name": "Hide Cloak", + "category": "tool", + "stack": 1, + "description": "Right-click to wear it (+15 warmth, less chill from wet). Take it off from the inventory screen.", + "mesh": "hide_cloak", + "wear": { + "warmth": 15, + "wet_chill": 0.6 + } } ] } diff --git a/data/recipes.json b/data/recipes.json index efd173e..573d965 100644 --- a/data/recipes.json +++ b/data/recipes.json @@ -134,6 +134,57 @@ "wood": 8, "fiber": 6 } + }, + { + "id": "cooked_meat", + "output": "cooked_meat", + "count": 1, + "station": "campfire", + "inputs": { + "raw_meat": 1 + } + }, + { + "id": "spear", + "output": "spear", + "count": 1, + "station": "workbench", + "inputs": { + "wood": 3, + "flint": 2, + "fiber": 1 + } + }, + { + "id": "bow", + "output": "bow", + "count": 1, + "station": "workbench", + "inputs": { + "wood": 4, + "fiber": 6 + } + }, + { + "id": "arrow", + "output": "arrow", + "count": 5, + "station": "workbench", + "inputs": { + "stick": 2, + "flint": 1, + "feather": 2 + } + }, + { + "id": "hide_cloak", + "output": "hide_cloak", + "count": 1, + "station": "workbench", + "inputs": { + "hide": 4, + "fiber": 4 + } } ] } diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b2d0290..ef5bb59 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -32,7 +32,8 @@ Detailed plan in `docs/SURVIVAL_DESIGN.md` (phases M1a–M1d). - [x] Weather (rain, storm, snow), wet/shelter *(M1c)* - [x] Snap-together building: floor/wall/door/roof/gable, resource cost, support + collapse, dismantling *(M1c)* - [x] Bed spawn point and sleeping, resource regrowth *(M1c)* -- [ ] Creatures, hunting, weapons *(M1d)* +- [x] Creatures (rabbit, deer, boar), hunting, club/spear/bow, butchering, cooked-food buffs, hide cloak *(M1d)* +- [ ] Wolves at night, hide armour, more recipes *(later M1 polish)* - **Done when:** gather → craft → build a tiny shelter, single-player. ## M2 — Multiplayer · ~2–4 months diff --git a/docs/SETUP_ON_NEW_MACHINE.md b/docs/SETUP_ON_NEW_MACHINE.md index adc9e0c..671d907 100644 --- a/docs/SETUP_ON_NEW_MACHINE.md +++ b/docs/SETUP_ON_NEW_MACHINE.md @@ -20,6 +20,8 @@ tests/run_all.sh ``` Or one at a time: ```bash +# Wildlife, melee, spear, bow, butchering, cooking + buffs, cloak, drops +godot --headless --path . --script res://tests/test_creatures_combat.gd # Weather, shelter, snap building, support, doors, beds, regrowth godot --headless --path . --script res://tests/test_weather_building.gd # Boundary: tries to walk/jump out of the valley in 24 directions diff --git a/docs/SURVIVAL_DESIGN.md b/docs/SURVIVAL_DESIGN.md index 78bf7e2..92c94d2 100644 --- a/docs/SURVIVAL_DESIGN.md +++ b/docs/SURVIVAL_DESIGN.md @@ -152,19 +152,57 @@ Built in M1b (`scripts/player/survival.gd`), current numbers: strength are global shader uniforms. Rain, wind and thunder sounds are synthesised from filtered noise, and rain is muffled under a roof. -### 2.8 Creatures & hunting *(M1d)* -- **Rabbit**: skittish, fast, gives a little meat. -- **Deer**: flees on sight or sound, gives meat and hide. -- **Boar**: neutral; charges if hit; gives meat and hide. -- *(later)* **Wolf**: night predator, hunts in packs. -- Simple state machines (idle / wander / flee / attack) on navigation-free - steering over the heightmap. Spawned around the player within budget. +### 2.8 Creatures & hunting *(built in M1d)* +| Animal | HP | Run speed | Notices you | Behaviour | Butchering gives | +|---|---|---|---|---|---| +| Rabbit | 10 | 7 m/s, zig-zags | 7 m (12 m if you sprint) | flees | 1 raw meat | +| Deer | 40 | 9 m/s | 16 m (30 m if you sprint) | flees, outruns you | 2-3 meat, 1-2 hide | +| Boar | 60 | 6.6 m/s | never | neutral; hit it and it charges and gores you for 14 every 1.6 s for 18 s, and flees below 30% HP | 3-4 meat, 1-2 hide | -### 2.9 Weapons & combat *(M1d)* -- Club (hand), wooden spear (thrust; can be thrown), bow + arrows (workbench; - needs feathers and flint). -- A melee swing is a short shape cast in front of the camera. It costs stamina - and has a cooldown. Damage numbers come from item data. +- *(later)* **Wolf**: night predator, hunts in packs. +- `scripts/creatures/creature.gd` runs each animal's state machine (idle, + wander, flee, charge, dead). Steering is over the heightmap with no navmesh: + a probe ahead turns the animal away from water, steep drops, the world edge + and trees, rocks or buildings. Legs are animated at the hips. The meshes are + procedural (`creature_meshes.gd`). +- `spawner.gd` keeps up to 6 rabbits, 4 deer and 3 boars, spawning 30-70 m + away and despawning them past 100 m. Each kind prefers its habitat: rabbits + in meadows, deer at forest edges, boars deep in the woods. A kind waits + 25 s to respawn after one dies. Animals aren't saved, so a loaded world + repopulates straight away. +- A kill leaves a **carcass** (on the interactable layer). E butchers it, and + arrows stuck in it come back to you. Carcasses rot away after 5 min. +- Feathers lie in the meadows as ground pickups (70, regrowing like sticks). + +### 2.9 Weapons & combat *(built in M1d)* +| Weapon | Recipe | Use | Damage | +|---|---|---|---| +| Bare hands, food, torch, resources | | | 0: you need a weapon (the prompt says so) | +| Axe / pickaxe | | LMB | 6 (poor weapons) | +| Wooden Club | 4 wood | LMB, reach 2.4 m | 12 | +| Wooden Spear | 3 wood, 2 flint, 1 fiber (workbench) | LMB thrust (reach 3.1 m); RMB throws it (24 m/s, then pick it up) | 18 / 30 thrown | +| Crude Bow | 4 wood, 6 fiber (workbench) | hold LMB to draw (0.9 s full), release | 8-32 by draw | +| Flint Arrow x5 | 2 sticks, 1 flint, 2 feathers (workbench) | ammunition; misses stick in the world and can be picked up | | + +- A melee swing is a box sweep in front of the camera, tall enough to reach a + rabbit at your feet. It costs stamina and has a per-weapon cooldown. + Animals are hit first, then trees and rocks. +- Arrows and thrown spears are projectiles (`projectile.gd`) with gravity, + ray-cast segment by segment so fast arrows can't skip through a target. + Damage scales with speed at impact. Arrows and spears lying in the world + are saved. +- Getting hurt flashes the screen red and stops natural healing for 6 s. + Animals and players use the same `damage()` path, ready for M2. + +### 2.9a Food buffs and clothing *(built in M1d)* +- Raw meat: +5 food, -4 health (it can't take you below 1). Cooked meat, made + at a campfire, gives +34 food, +12 health and **Well fed** for 10 min: + stamina regen x1.3 and healing x2. The HUD shows the time left. Buffs are + saved. They don't stack, so the strongest applies. +- **Hide Cloak** (4 hide + 4 fiber at a workbench): RMB to wear it. It gives + +15 warmth and cuts the chill from being wet by 40%. You take it off from + the inventory screen. It's saved, and it goes into your grave when you + die. ### 2.10 Saving & worlds *(built)* - **One continuous world per save slot.** The title screen offers Continue @@ -210,7 +248,7 @@ Built in M1b (`scripts/player/survival.gd`), current numbers: | **M1a** *(done)* | items/recipes data, inventory + hotbar, pickups, chop/mine with tools, crafting UI, campfire / workbench / chest placement, chest storage, pause menu, **title screen + world slots + save/load + autosave** (pulled forward from M1c) | you craft an axe, fell a tree, build a fire, stash wood in a chest, quit and continue | | **M1b** *(done)* | hunger/warmth/stamina/health HUD, day/night cycle with moon and stars, campfire warmth + fuel, cooking, bushes (fiber, berries), mushrooms, torches, **death + grave bag** (pulled forward from M1c) | you survive your first night by the fire | | **M1c** *(done)* | weather (rain, storm, snow, blizzard, lightning, sound) + wet/shelter/rested, snap building (floor/wall/door/roof/gable) with support, dismantling and collapse, bed spawn point + sleeping, resource regrowth | you build a hut and wait out a storm | -| **M1d** | rabbit/deer/boar AI, club/spear/bow, hides, cooked food buffs | you hunt a deer and eat its meat | +| **M1d** *(done)* | rabbit/deer/boar AI and spawning, club/spear (thrown)/bow + arrows, butchering, hides, hide cloak, cooked meat + Well fed buff, feathers | you hunt a deer and eat its meat | ## 5. Open decisions (current defaults in bold) - Thirst stat? **No**, to keep the stats readable (Valheim has none). diff --git a/scripts/core/actions.gd b/scripts/core/actions.gd index 9fa8b4f..4f5dbd9 100644 --- a/scripts/core/actions.gd +++ b/scripts/core/actions.gd @@ -191,6 +191,134 @@ func use_bed(player: Node3D, bed: Node3D) -> bool: world.sleep(player) return true +# --- hunting and combat --------------------------------------------------------- + +## Bare hands (or berries, a torch, a stone...) can't hurt an animal: you +## need a weapon, or at least an axe or pickaxe. +const NO_WEAPON := {"damage": 0.0, "reach": 2.0, "stamina": 0.0, "cooldown": 0.45, "armed": false} +const TOOL_AS_WEAPON := 6.0 ## axes and pickaxes are poor weapons +const NEED_WEAPON := "You need a weapon: a club, spear, bow or axe (Tab to craft)" + +## Melee stats for what's in the player's hand. "armed" is false when it +## can't hurt an animal. +func melee_stats(player: Node) -> Dictionary: + var held: Dictionary = ItemDB.get_item(player.held_item_id()) + var w: Dictionary = held.get("weapon", {}) + if float(w.get("damage", 0)) > 0.0: + return {"damage": float(w.damage), "reach": float(w.get("reach", 2.2)), + "stamina": float(w.get("stamina", 8)), "cooldown": float(w.get("cooldown", 0.5)), "armed": true} + if held.has("tool"): + return {"damage": TOOL_AS_WEAPON, "reach": 2.3, "stamina": 6.0, "cooldown": 0.5, "armed": true} + return NO_WEAPON + +## One melee blow at an animal. +func melee(player: Node3D, target: Node3D) -> bool: + if not is_instance_valid(target) or not target.is_in_group("creature"): + return false + var m := melee_stats(player) + if not m.armed: + notify.emit(NEED_WEAPON) + return false + if player.global_position.distance_to(target.global_position) > m.reach + 1.8: + return false + if not player.survival.use_stamina(m.stamina): + notify.emit("Too tired") + return false + target.take_hit(m.damage, player.global_position) + if target.is_dead(): + notify.emit("Killed a %s" % target.display_name().to_lower()) + return true + +## An arrow or thrown spear reaching an animal (projectile.gd). +func projectile_hit(shooter: Node3D, target: Node3D, damage: float, at: Vector3, embed_id: String) -> void: + if not is_instance_valid(target) or not target.is_in_group("creature"): + return + var was_alive: bool = not target.is_dead() + target.take_hit(damage, shooter.global_position if shooter != null else at, embed_id) + if was_alive and target.is_dead(): + notify.emit("Killed a %s" % target.display_name().to_lower()) + +## Throws the spear in `slot` from `origin` along `dir`. +func throw_spear(player: Node3D, slot: int, origin: Vector3, dir: Vector3, world: Node3D) -> bool: + var s = player.inventory.get_slot(slot) + if s == null: + return false + var w: Dictionary = ItemDB.get_item(s.id).get("weapon", {}) + if not w.has("throw") or player.global_position.distance_to(origin) > 2.5: + return false + if not player.survival.use_stamina(float(w.throw.get("stamina", 12))): + notify.emit("Too tired") + return false + var id: String = s.id + player.inventory.take_from(slot, 1) + world.launch(id, origin, dir.normalized() * float(w.throw.speed) + player.velocity * 0.5, + float(w.throw.damage), player) + return true + +## Starts drawing the held bow: needs arrows and a little stamina. +func can_draw(player: Node3D) -> bool: + var bow: Dictionary = ItemDB.get_item(player.held_item_id()).get("bow", {}) + if bow.is_empty(): + return false + if player.inventory.count(bow.ammo) <= 0: + notify.emit("No arrows (craft them at a workbench: sticks, flint, feathers)") + return false + if not player.survival.use_stamina(float(bow.stamina)): + notify.emit("Too tired") + return false + return true + +## Looses an arrow. `power` 0..1 is how far the bow was drawn. +func shoot(player: Node3D, origin: Vector3, dir: Vector3, power: float, world: Node3D) -> bool: + var bow: Dictionary = ItemDB.get_item(player.held_item_id()).get("bow", {}) + if bow.is_empty() or player.global_position.distance_to(origin) > 2.5: + return false + if not player.inventory.remove(bow.ammo, 1): + return false + power = clampf(power, 0.0, 1.0) + var spd: float = lerpf(float(bow.speed[0]), float(bow.speed[1]), power) + var dmg: float = lerpf(float(bow.damage[0]), float(bow.damage[1]), power) + world.launch(bow.ammo, origin, dir.normalized() * spd, dmg, player) + return true + +func butcher(player: Node3D, carcass: Node3D) -> bool: + if not is_instance_valid(carcass) or not carcass.is_in_group("carcass") or not _in_reach(player, carcass, 1.0): + return false + var drops: Dictionary = carcass.butcher_drops() + carcass.remove_from_group("carcass") + for id in drops: + var got := _give(player, id, int(drops[id])) + if got < int(drops[id]): + # Inventory full: the rest drops on the ground. + carcass.world.drop_item(id, int(drops[id]) - got, Transform3D(Basis(), carcass.global_position + Vector3(0, 0.1, 0))) + carcass.queue_free() + return true + +## Puts on the cloak in `slot` (swapping out any cloak you were wearing). +func wear(player: Node3D, slot: int) -> bool: + var s = player.inventory.get_slot(slot) + if s == null or not ItemDB.get_item(s.id).has("wear"): + return false + var id: String = s.id + player.inventory.take_from(slot, 1) + if not player.worn.is_empty(): + _give(player, player.worn, 1) + player.worn = id + player.worn_changed.emit() + notify.emit("Wearing %s" % ItemDB.item_name(id).to_lower()) + return true + +func take_off(player: Node3D) -> bool: + if player.worn.is_empty(): + return false + if player.inventory.space_for(player.worn) < 1: + notify.emit("Inventory full") + return false + _give(player, player.worn, 1) + player.worn = "" + player.worn_changed.emit() + return true + func forage(player: Node3D, bush: Node3D) -> bool: if not is_instance_valid(bush) or not _in_reach(player, bush) or not bush.ripe(): return false diff --git a/scripts/creatures/creature.gd b/scripts/creatures/creature.gd new file mode 100644 index 0000000..bc16ab0 --- /dev/null +++ b/scripts/creatures/creature.gd @@ -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) diff --git a/scripts/creatures/creature_meshes.gd b/scripts/creatures/creature_meshes.gd new file mode 100644 index 0000000..64f6799 --- /dev/null +++ b/scripts/creatures/creature_meshes.gd @@ -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} diff --git a/scripts/creatures/projectile.gd b/scripts/creatures/projectile.gd new file mode 100644 index 0000000..3b4d0b5 --- /dev/null +++ b/scripts/creatures/projectile.gd @@ -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() diff --git a/scripts/creatures/spawner.gd b/scripts/creatures/spawner.gd new file mode 100644 index 0000000..03b9711 --- /dev/null +++ b/scripts/creatures/spawner.gd @@ -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 diff --git a/scripts/items/item_meshes.gd b/scripts/items/item_meshes.gd index dace5ae..c915d86 100644 --- a/scripts/items/item_meshes.gd +++ b/scripts/items/item_meshes.gd @@ -57,6 +57,14 @@ static func build(key: String) -> ArrayMesh: "wood_roof": m = _roof() "wood_gable": m = _gable() "bed": m = _bed() + "raw_meat": m = _meat(MEAT_RAW) + "cooked_meat": m = _meat(MEAT_COOKED) + "hide": m = _hide() + "feather": m = _feather() + "spear": m = _spear() + "bow": m = _bow() + "arrow": m = _arrow() + "hide_cloak": m = _cloak() "berry_bush": m = _berry_bush(true) "berry_bush_bare": m = _berry_bush(false) _: @@ -373,3 +381,89 @@ static func _bed() -> ArrayMesh: _box(st, Vector3(0, 0.5, -0.72), Vector3(0.6, 0.1, 0.32), LINEN) _box(st, Vector3(0, 0.47, 0.25), Vector3(0.9, 0.08, 1.3), FUR) return P._commit(st) + +# --- hunting gear (M1d) ------------------------------------------------------- + +const MEAT_RAW := Color(0.72, 0.24, 0.24) +const MEAT_COOKED := Color(0.48, 0.26, 0.12) +const BONE := Color(0.90, 0.87, 0.78) +const HIDE := Color(0.58, 0.42, 0.28) +const FEATHER := Color(0.88, 0.86, 0.80) +const FLETCH := Color(0.70, 0.22, 0.18) + +static func _meat(col: Color) -> ArrayMesh: + var st := P._begin() + var r := _rng(107) + var n := FastNoiseLite.new() + n.seed = 107 + n.frequency = 2.2 + P._blob(st, Vector3(0.03, 0.07, 0), Vector3(0.13, 0.07, 0.1), n, 0.25, col, r, 1) + P._cylinder(st, Vector3(-0.08, 0.07, 0), Vector3(-0.2, 0.08, 0.01), 0.022, 0.018, 5, BONE, r, -1.0, BONE) + P._blob(st, Vector3(-0.21, 0.08, 0.01), Vector3(0.03, 0.03, 0.035), n, 0.1, BONE, r, 0) + return P._commit(st) + +static func _hide() -> ArrayMesh: + var st := P._begin() + var r := _rng(109) + # A rough, folded pelt: a few overlapping flattened slabs. + _box(st, Vector3(0, 0.02, 0), Vector3(0.36, 0.03, 0.26), HIDE, Basis(Vector3.UP, 0.2)) + _box(st, Vector3(0.05, 0.05, 0.02), Vector3(0.28, 0.03, 0.2), P._mul(HIDE, 0.85), Basis(Vector3.UP, -0.3)) + _box(st, Vector3(-0.04, 0.075, -0.02), Vector3(0.2, 0.025, 0.16), _board(r, HIDE), Basis(Vector3.UP, 0.6)) + return P._commit(st) + +static func _feather() -> ArrayMesh: + var st := P._begin() + var r := _rng(113) + P._cylinder(st, Vector3(-0.14, 0.01, 0), Vector3(0.14, 0.012, 0), 0.004, 0.002, 3, BONE, r) + for side in [-1.0, 1.0]: + var a := Vector3(-0.1, 0.01, 0) + var b := Vector3(0.13, 0.012, 0) + var c := Vector3(0.0, 0.01, side * 0.035) + P._tri(st, a, b, c, Vector3.UP, FEATHER) + P._tri(st, a, b, c, Vector3.DOWN, P._mul(FEATHER, 0.85)) + return P._commit(st) + +## Long shaft along +Y from the grip, fire-hardened point with a flint tip. +static func _spear() -> ArrayMesh: + var st := P._begin() + var r := _rng(127) + P._cylinder(st, Vector3(0, -0.55, 0), Vector3(0, 0.75, 0), 0.022, 0.02, 6, HANDLE, r, -1.0, HANDLE) + _box(st, Vector3(0, 0.78, 0), Vector3(0.05, 0.06, 0.05), BINDING) + P._cylinder(st, Vector3(0, 0.8, 0), Vector3(0, 0.98, 0), 0.035, 0.004, 4, FLINT, r) + return P._commit(st) + +## Bent stave along +Y with a string on the +Z side (held upright). +static func _bow() -> ArrayMesh: + var st := P._begin() + var r := _rng(131) + var pts: Array[Vector3] = [] + for i in range(7): + var t := -1.0 + 2.0 * i / 6.0 + pts.append(Vector3(0, t * 0.55, -0.1 * (1.0 - t * t))) + for i in range(6): + P._cylinder(st, pts[i], pts[i + 1], 0.02 if absf(i - 2.5) < 1.5 else 0.015, 0.02 if absf(i - 2.5) < 1.5 else 0.013, 5, PLANK_DARK, r, 0.0) + P._cylinder(st, pts[0], pts[6], 0.004, 0.004, 3, LINEN, r) + _box(st, Vector3(0, 0, -0.1), Vector3(0.05, 0.12, 0.05), BINDING) + return P._commit(st) + +## Arrow along -Z (its flight direction), tip at the front. +static func _arrow() -> ArrayMesh: + var st := P._begin() + var r := _rng(137) + P._cylinder(st, Vector3(0, 0, 0.35), Vector3(0, 0, -0.33), 0.008, 0.008, 4, HANDLE, r, 0.0) + P._cylinder(st, Vector3(0, 0, -0.32), Vector3(0, 0, -0.4), 0.018, 0.002, 4, FLINT, r, 0.0) + for a in [0.0, TAU / 3.0, TAU * 2.0 / 3.0]: + var d := Vector3(cos(a), sin(a), 0) * 0.03 + P._tri(st, Vector3(0, 0, 0.33), Vector3(0, 0, 0.2), d + Vector3(0, 0, 0.3), Vector3(-d.y, d.x, 0), FLETCH) + P._tri(st, Vector3(0, 0, 0.33), Vector3(0, 0, 0.2), d + Vector3(0, 0, 0.3), Vector3(d.y, -d.x, 0), FLETCH) + return P._commit(st) + +static func _cloak() -> ArrayMesh: + var st := P._begin() + var r := _rng(139) + # Folded cloak: a stack of fur with a bone clasp. + for i in range(3): + _box(st, Vector3(0, 0.03 + i * 0.045, 0), Vector3(0.42 - i * 0.05, 0.04, 0.3 - i * 0.03), _board(r, HIDE), Basis(Vector3.UP, (i - 1) * 0.08)) + _box(st, Vector3(0, 0.17, 0.0), Vector3(0.32, 0.05, 0.08), P._mul(FUR, 1.1)) + _box(st, Vector3(0.1, 0.2, 0.05), Vector3(0.05, 0.03, 0.03), BONE) + return P._commit(st) diff --git a/scripts/player/hands.gd b/scripts/player/hands.gd index ec342cc..8c604d6 100644 --- a/scripts/player/hands.gd +++ b/scripts/player/hands.gd @@ -28,6 +28,10 @@ var _ghost_yaw := 0.0 var _place_xf := Transform3D() var _place_ok := false var _place_reason := "" +var _rest_default := Transform3D() +var _rest_bow := Transform3D() +var _drawing := false +var _draw_t := 0.0 var _prompt := "" var _held_shown := "~" var _torch_light: OmniLight3D @@ -37,7 +41,7 @@ var _t := 0.0 func _ready() -> void: _ray = RayCast3D.new() _ray.target_position = Vector3(0, 0, -REACH) - _ray.collision_mask = 1 | 2 + _ray.collision_mask = 1 | 2 | 8 # world, interactables, animals _ray.collide_with_areas = true _ray.add_exception(player) add_child(_ray) @@ -47,6 +51,8 @@ func _ready() -> void: _view.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF _view_rest = Transform3D(Basis.from_euler(Vector3(deg_to_rad(-25), deg_to_rad(-10), deg_to_rad(-20))), Vector3(0.36, -0.42, -0.62)) _view.transform = _view_rest + _rest_default = _view_rest + _rest_bow = Transform3D(Basis.from_euler(Vector3(deg_to_rad(-8), deg_to_rad(80), deg_to_rad(-12))), Vector3(0.3, -0.3, -0.6)) add_child(_view) _ghost_ok_mat = _ghost_material(Color(0.3, 1.0, 0.4, 0.45)) @@ -95,6 +101,9 @@ func _refresh_held() -> void: _held_shown = id var def := _held_def() _view.visible = not id.is_empty() and not def.has("place") + # A bow is held side-on, so you see its curve and string. + _view_rest = _rest_bow if def.has("bow") else _rest_default + _view.transform = _view_rest if _view.visible: _view.mesh = ItemDB.mesh_for(id) var s := 0.85 if def.category in ["tool", "weapon"] else 0.6 @@ -157,8 +166,24 @@ func _physics_process(delta: float) -> void: _set_prompt(p) elif def.has("food"): _set_prompt("[RMB] Eat %s" % def.name) + elif def.has("wear"): + _set_prompt("[RMB] Wear %s" % def.name) + elif def.has("bow"): + var n: int = player.inventory.count(def.bow.ammo) + _set_prompt("Drawing %d%%" % roundi(draw_power() * 100.0) if _drawing else "[hold LMB] Draw (%d arrows)" % n) + elif def.get("weapon", {}).has("throw"): + _set_prompt("[LMB] Thrust [RMB] Throw") else: _set_prompt("") + if _drawing: + if not def.has("bow"): + _drawing = false # switched away mid-draw + _view.transform = _view_rest + else: + _draw_t += delta + var p := draw_power() + # Pull the bow back toward you as it's drawn. + _view.transform = Transform3D(_view_rest.basis, _view_rest.origin.lerp(Vector3(0.08, -0.22, -0.5), p)) func _update_ghost(def: Dictionary) -> void: var cam := get_parent() as Camera3D @@ -197,9 +222,17 @@ func _unhandled_input(event: InputEvent) -> void: player.select(player.selected - 1) elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN: player.select(player.selected + 1) + if event.is_action_released("attack") and _drawing: + _release_bow() + return if event.is_action_pressed("use_item") and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: - if _held_def().has("food"): + var d := _held_def() + if d.has("food"): Actions.eat(player, player.selected) + elif d.has("wear"): + Actions.wear(player, player.selected) + elif d.get("weapon", {}).has("throw"): + _throw() elif event.is_action_pressed("rotate_piece"): # Building pieces turn in quarter turns (they snap to a grid). _ghost_yaw += deg_to_rad(90.0 if BuildPiece.is_build(_held_def()) else 22.5) @@ -217,14 +250,50 @@ func _unhandled_input(event: InputEvent) -> void: Actions.place(player, player.selected, _place_xf, player.get_parent()) else: Actions.notify.emit("Can't place that here") + elif _held_def().has("bow"): + _start_draw() else: swing() +# --- bow and spear -------------------------------------------------------------- + +func _aim() -> Array: + var cam := get_parent() as Camera3D + var fwd := -cam.global_basis.z + return [cam.global_position + fwd * 0.5, fwd] + +func _start_draw() -> void: + if _drawing or _swing_cd > 0.0 or not Actions.can_draw(player): + return + _drawing = true + _draw_t = 0.0 + +func draw_power() -> float: + var bow: Dictionary = _held_def().get("bow", {}) + return clampf(_draw_t / float(bow.get("draw", 1.0)), 0.0, 1.0) if _drawing else 0.0 + +func _release_bow() -> void: + var p := draw_power() + _drawing = false + _view.transform = _view_rest + if p < 0.15: + return # barely drawn: let the string down, nothing fired + var a := _aim() + Actions.shoot(player, a[0], a[1], p, player.get_parent()) + _swing_cd = 0.3 + +func _throw() -> void: + if _swing_cd > 0.0: + return + var a := _aim() + if Actions.throw_spear(player, player.selected, a[0] + Vector3(0, -0.05, 0), (a[1] as Vector3) + Vector3(0, 0.06, 0), player.get_parent()): + _swing_cd = 0.6 + ## Swings the held item (or fist). The hit is applied at the peak of the swing. func swing() -> void: if _swing_cd > 0.0: return - _swing_cd = SWING_TIME + _swing_cd = float(Actions.melee_stats(player).cooldown) var tw := create_tween() var wind := Transform3D(_view_rest.basis.rotated(Vector3.RIGHT, deg_to_rad(35)), _view_rest.origin + Vector3(0, 0.08, 0.05)) var strike := Transform3D(_view_rest.basis.rotated(Vector3.RIGHT, deg_to_rad(-70)), _view_rest.origin + Vector3(-0.05, -0.05, -0.15)) @@ -234,9 +303,37 @@ func swing() -> void: tw.tween_property(_view, "transform", _view_rest, 0.25).set_trans(Tween.TRANS_SINE) func _apply_hit() -> void: + # Animals first: a short box sweep in front of the camera, so you don't + # need pixel-perfect aim at a running rabbit. + var animal := melee_target() + if animal != null: + Actions.melee(player, animal) + return _ray.force_raycast_update() if not _ray.is_colliding(): return var c := _ray.get_collider() if c != null and c.is_in_group("harvestable"): Actions.harvest_hit(player, c, _ray.get_collision_point()) + +## The nearest living animal inside the melee sweep, or null. +func melee_target() -> Node3D: + var cam := get_parent() as Camera3D + var reach: float = Actions.melee_stats(player).reach + # Tall enough (down to your feet when looking level) to catch a rabbit. + var box := BoxShape3D.new() + box.size = Vector3(0.9, 2.2, reach) + var q := PhysicsShapeQueryParameters3D.new() + q.shape = box + q.transform = Transform3D(cam.global_basis, cam.global_position - cam.global_basis.z * reach * 0.5 - cam.global_basis.y * 0.5) + q.collision_mask = 8 + var best: Node3D = null + var best_d := INF + for h in cam.get_world_3d().direct_space_state.intersect_shape(q, 8): + var c: Node3D = h.collider + if c.is_in_group("creature"): + var d := c.global_position.distance_to(cam.global_position) + if d < best_d: + best_d = d + best = c + return best diff --git a/scripts/player/player.gd b/scripts/player/player.gd index 08a935b..766a9eb 100644 --- a/scripts/player/player.gd +++ b/scripts/player/player.gd @@ -27,6 +27,8 @@ var selected := 0 var ui_open := false ## set by the inventory screen; freezes look/move var hands: Node3D var survival: Node ## health / stamina / hunger / warmth (survival.gd) +var worn := "" ## item id of the cloak you're wearing ("" = none) +signal worn_changed var _gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity", 9.8) diff --git a/scripts/player/survival.gd b/scripts/player/survival.gd index 773d3cb..97aecf6 100644 --- a/scripts/player/survival.gd +++ b/scripts/player/survival.gd @@ -35,6 +35,12 @@ var sheltered := false ## roof and walls (the weather barely reaches you) var rested := 0.0 ## seconds of Rested left var _shelter_left := 0.0 var _rest_build := 0.0 +## Food buffs: id -> {name, left (s), stamina_regen, health_regen (multipliers)}. +var buffs: Dictionary = {} +var _hurt_cd := 0.0 ## no natural healing for a few seconds after a hit + +signal hurt(amount: float, from: Vector3) +const HURT_NO_REGEN := 6.0 var player: CharacterBody3D var world: Node3D @@ -65,9 +71,10 @@ func step(dt: float) -> void: else: _rest_build = 0.0 rested = maxf(rested - dt, 0.0) - var target: float = world.air_warmth(p, sheltered) + heat * 70.0 + var wear: Dictionary = ItemDB.get_item(player.worn).get("wear", {}) if not player.worn.is_empty() else {} + var target: float = world.air_warmth(p, sheltered) + heat * 70.0 + float(wear.get("warmth", 0.0)) if wet > 0.0: - target -= 22.0 + target -= 22.0 * float(wear.get("wet_chill", 1.0)) wet = maxf(wet - dt * (3.0 if heat > 0.3 else 1.0), 0.0) # fires dry you off target = clampf(target, 0.0, MAX) # Warm up faster than you cool down: getting to a fire should feel good. @@ -78,7 +85,13 @@ func step(dt: float) -> void: if _stamina_wait > 0.0: _stamina_wait -= dt else: - stamina = minf(stamina + STAMINA_REGEN * (1.5 if rested > 0.0 else 1.0) * dt, MAX) + stamina = minf(stamina + STAMINA_REGEN * (1.5 if rested > 0.0 else 1.0) * buff_mult("stamina_regen") * dt, MAX) + + for id in buffs.keys(): + buffs[id].left -= dt + if buffs[id].left <= 0.0: + buffs.erase(id) + _hurt_cd = maxf(_hurt_cd - dt, 0.0) var dmg := 0.0 if hunger <= 0.0: @@ -87,8 +100,8 @@ func step(dt: float) -> void: dmg += 0.5 if dmg > 0.0: health = maxf(health - dmg * dt, 0.0) - elif hunger > 40.0 and warmth > 35.0: - health = minf(health + 0.5 * dt, MAX) + elif hunger > 40.0 and warmth > 35.0 and _hurt_cd <= 0.0: + health = minf(health + 0.5 * buff_mult("health_regen") * dt, MAX) _update_statuses() changed.emit() @@ -114,6 +127,8 @@ func _update_statuses() -> void: statuses.append("Sheltered") if rested > 0.0: statuses.append("Rested") + for id in buffs: + statuses.append("%s %d:%02d" % [buffs[id].name, int(buffs[id].left) / 60, int(buffs[id].left) % 60]) ## Spends stamina for an action. Returns false (and spends nothing) if you're ## too tired. Continuous costs (sprinting) pass `partial = true`. @@ -126,7 +141,10 @@ func use_stamina(amount: float, partial: bool = false) -> bool: func eat(food: Dictionary) -> void: hunger = minf(hunger + float(food.get("hunger", 0)), MAX) - health = minf(health + float(food.get("health", 0)), MAX) + # Raw meat can hurt a little, but never kills you. + health = clampf(health + float(food.get("health", 0)), minf(health, 1.0), MAX) + if food.has("buff"): + add_buff(food.buff) changed.emit() func soak() -> void: @@ -149,10 +167,38 @@ func reset_after_death() -> void: hunger = 50.0 warmth = 60.0 wet = 0.0 + buffs.clear() changed.emit() +## Takes a hit (a boar, later wolves). Pauses natural healing briefly. +func damage(amount: float, from: Vector3) -> void: + if dead: + return + health = maxf(health - amount, 0.0) + _hurt_cd = HURT_NO_REGEN + hurt.emit(amount, from) + changed.emit() + if health <= 0.0: + dead = true + died.emit() + +## Multiplier from food buffs for one effect (1.0 = none). Buffs don't stack: +## the strongest applies. +func buff_mult(key: String) -> float: + var m := 1.0 + for id in buffs: + m = maxf(m, float(buffs[id].get(key, 1.0))) + return m + +func add_buff(b: Dictionary) -> void: + var id: String = b.get("id", "buff") + buffs[id] = {"name": b.get("name", id), "left": float(b.get("seconds", 300.0)), + "stamina_regen": float(b.get("stamina_regen", 1.0)), "health_regen": float(b.get("health_regen", 1.0))} + _update_statuses() + func to_data() -> Dictionary: - return {"health": health, "stamina": stamina, "hunger": hunger, "warmth": warmth, "wet": wet, "rested": rested} + return {"health": health, "stamina": stamina, "hunger": hunger, "warmth": warmth, "wet": wet, "rested": rested, + "buffs": buffs.duplicate(true)} func load_data(d: Dictionary) -> void: health = float(d.get("health", health)) @@ -161,5 +207,6 @@ func load_data(d: Dictionary) -> void: warmth = float(d.get("warmth", warmth)) wet = float(d.get("wet", 0.0)) rested = float(d.get("rested", 0.0)) + buffs = (d.get("buffs", {}) as Dictionary).duplicate(true) dead = false changed.emit() diff --git a/scripts/ui/hud.gd b/scripts/ui/hud.gd index a7291b1..1a3dfc7 100644 --- a/scripts/ui/hud.gd +++ b/scripts/ui/hud.gd @@ -18,6 +18,13 @@ var _menu: Control var _bars: Dictionary = {} var _status: Label var _clock: Label +var _hurt: ColorRect + +## A red flash at the screen edge when something hits you. +func _on_hurt(amount: float, _from: Vector3) -> void: + _hurt.color.a = clampf(0.15 + amount / 60.0, 0.15, 0.45) + var tw := _hurt.create_tween() + tw.tween_property(_hurt, "color:a", 0.0, 0.6) func bind(p: CharacterBody3D) -> void: player = p @@ -93,6 +100,13 @@ func bind(p: CharacterBody3D) -> void: player.selected_changed.connect(_refresh) player.inventory_requested.connect(_on_inventory_requested) player.hands.prompt_changed.connect(func(t): _prompt.text = t) + _hurt = ColorRect.new() + _hurt.color = Color(0.7, 0.0, 0.0, 0.0) + _hurt.set_anchors_preset(Control.PRESET_FULL_RECT) + _hurt.mouse_filter = Control.MOUSE_FILTER_IGNORE + root.add_child(_hurt) + root.move_child(_hurt, 0) + player.survival.hurt.connect(_on_hurt) Actions.notify.connect(toast) ItemDB.icons_ready.connect(_refresh) _refresh() diff --git a/scripts/ui/inventory_screen.gd b/scripts/ui/inventory_screen.gd index 0ede22e..01d2151 100644 --- a/scripts/ui/inventory_screen.gd +++ b/scripts/ui/inventory_screen.gd @@ -21,6 +21,8 @@ var _chest_grid: GridContainer var _chest_title: Label var _recipe_list: VBoxContainer var _station_label: Label +var _worn_label: Label +var _worn_btn: Button var _cursor = null ## stack held on the mouse var _cursor_view: Control var _cursor_icon: TextureRect @@ -73,6 +75,19 @@ func bind(p: CharacterBody3D) -> void: pass pv.add_child(grid) pv.add_child(UITheme.label("Row 1 is your hotbar. Shift-click moves stacks to/from a chest.", 13, UITheme.TEXT_DIM)) + var wr := HBoxContainer.new() + wr.add_theme_constant_override("separation", 10) + _worn_label = UITheme.label("", 15) + wr.add_child(_worn_label) + _worn_btn = Button.new() + _worn_btn.text = "Take off" + _worn_btn.focus_mode = Control.FOCUS_NONE + _worn_btn.pressed.connect(func(): + Actions.take_off(player) + _refresh()) + wr.add_child(_worn_btn) + pv.add_child(wr) + player.worn_changed.connect(_refresh) pp.add_child(pv) row.add_child(pp) @@ -203,6 +218,8 @@ func _refresh() -> void: if _cursor != null: _cursor_icon.texture = ItemDB.icon(_cursor.id) _cursor_count.text = str(_cursor.count) if _cursor.count > 1 else "" + _worn_label.text = "Wearing: %s" % (ItemDB.item_name(player.worn) if not player.worn.is_empty() else "nothing") + _worn_btn.visible = not player.worn.is_empty() _rebuild_recipes() func _rebuild_recipes() -> void: diff --git a/scripts/ui/pause_menu.gd b/scripts/ui/pause_menu.gd index cc572e6..f2727cf 100644 --- a/scripts/ui/pause_menu.gd +++ b/scripts/ui/pause_menu.gd @@ -8,7 +8,8 @@ const UITheme = preload("res://scripts/ui/theme.gd") const CONTROLS := [ ["W A S D", "Move"], ["Shift", "Sprint"], ["Space", "Jump / swim up"], ["Mouse", "Look"], ["E", "Interact / pick up / add fuel"], ["Left click", "Use tool / place"], - ["Right click (holding food)", "Eat"], + ["Right click (holding food)", "Eat"], ["Right click (spear / cloak)", "Throw / wear"], + ["Hold left click (bow)", "Draw, release to shoot"], ["R", "Rotate piece (building pieces: quarter turns)"], ["X", "Dismantle the piece you're looking at"], ["E on a bed", "Set spawn point / sleep (night, under a roof)"], ["1 - 8, wheel", "Hotbar"], ["Tab", "Inventory & crafting (shift-click Craft for 5)"], diff --git a/scripts/world/pickup.gd b/scripts/world/pickup.gd index 6f6ab09..ae9ed3d 100644 --- a/scripts/world/pickup.gd +++ b/scripts/world/pickup.gd @@ -25,18 +25,24 @@ func setup(id: String, mesh: Mesh, material: Material, radius: float) -> void: add_child(cs) func interact_prompt(_player: Node) -> String: - return "[E] Pick up %s" % ItemDB.item_name(item_id) + return "[E] Pick up %s%s" % [ItemDB.item_name(item_id), " x%d" % amount if amount > 1 else ""] func interact(player: Node) -> void: Actions.pickup(player, self) signal taken_now(pickup: Node) +var dropped := false ## dropped by the player (arrows, spears): saved, never regrows + ## Picked up: hidden (not freed) so it can reappear later (props.gd regrowth). +## Dropped items just go. func collected() -> void: if taken: return taken = true + if dropped: + queue_free() + return visible = false collision_layer = 0 taken_now.emit(self) diff --git a/scripts/world/props.gd b/scripts/world/props.gd index b33d791..1186d08 100644 --- a/scripts/world/props.gd +++ b/scripts/world/props.gd @@ -581,8 +581,8 @@ func _scatter_pickups() -> void: var rng := RandomNumberGenerator.new() rng.seed = world_seed + 6 # Order matters: new kinds go at the END so existing saves keep their ids. - var want := {"stick": 260, "stone": 170, "flint": 80, "mushroom": 110} - var radius: Dictionary = {"stick": 0.35, "stone": 0.25, "flint": 0.25, "mushroom": 0.25} + var want := {"stick": 260, "stone": 170, "flint": 80, "mushroom": 110, "feather": 70} + var radius: Dictionary = {"stick": 0.35, "stone": 0.25, "flint": 0.25, "mushroom": 0.25, "feather": 0.25} for id: String in want: var mesh := ItemDB.mesh_for(id) var placed := 0 @@ -608,6 +608,8 @@ func _scatter_pickups() -> void: chance = 0.06 + shore * 0.9 + (1.0 - n.y) * 2.0 "mushroom": chance = _forest(x, z) * 0.7 # deep forest only + "feather": + chance = 0.1 + (1.0 - _forest(x, z)) * 0.5 # open meadows # A few right by the spawn so the first craft is quick. if Vector2(x - _spawn.x, z - _spawn.z).length() < 18.0: chance += 0.5 diff --git a/scripts/world/world.gd b/scripts/world/world.gd index 66c13ee..cc6835c 100644 --- a/scripts/world/world.gd +++ b/scripts/world/world.gd @@ -45,6 +45,8 @@ var day_night: Node ## day_night.gd: time of day, sun, sky var weather: Node ## weather.gd: the weather model var weather_fx: Node3D ## weather_fx.gd: rain/snow particles, sound var build: Node ## build_system.gd: snapping, support, shelter +var creatures: Node3D ## spawner.gd: wildlife around the player +var _dropped: Node3D ## arrows, spears and other dropped items var has_bed_spawn := false var bed_spawn := Vector3.ZERO ## position of the bed you respawn at var _loading := false ## applying a save: defer support recompute @@ -68,6 +70,13 @@ func _ready() -> void: build.name = "Build" add_child(build) build.setup(self) + _dropped = Node3D.new() + _dropped.name = "Dropped" + add_child(_dropped) + creatures = preload("res://scripts/creatures/spawner.gd").new() + creatures.name = "Creatures" + creatures.enabled = false # until the scenery is placed + add_child(creatures) if not menu_mode and DisplayServer.get_name() != "headless": weather_fx = preload("res://scripts/world/weather_fx.gd").new() weather_fx.name = "WeatherFX" @@ -131,6 +140,10 @@ func _on_player_died() -> void: if pos.y < terrain.water_level: # Drowned or died in the lake: put the grave on the nearest dry shore. pos = terrain.find_spawn(Vector2(pos.x, pos.z)) + # What you were wearing goes in the grave too. + if not player.worn.is_empty() and player.inventory.add(player.worn, 1) == 0: + player.worn = "" + player.worn_changed.emit() var has_items := false for st in player.inventory.slots: if st != null: @@ -225,6 +238,10 @@ func _on_terrain_ready() -> void: print("[world] loaded '%s'" % Saves.current_id) player.set_physics_process(true) set_physics_process(true) + creatures.setup(self) + if not "--no-creatures" in OS.get_cmdline_user_args(): + creatures.populate() + creatures.enabled = true _maybe_capture() func _process(delta: float) -> void: @@ -291,6 +308,11 @@ func collect_save() -> Dictionary: var pk = props.pickups[uid] if not is_instance_valid(pk) or pk.taken: taken.append(uid) + var dropped: Array = [] + for d in _dropped.get_children(): + if d.is_queued_for_deletion() or d.taken: + continue + dropped.append({"item": d.item_id, "count": d.amount, "xf": _xf_to_array(d.global_transform)}) var pieces: Array = [] for piece in _pieces.get_children(): if piece.is_queued_for_deletion(): @@ -309,7 +331,9 @@ func collect_save() -> Dictionary: "inventory": player.inventory.to_data(), "selected": player.selected, "stats": player.survival.to_data(), + "worn": player.worn, }, + "dropped": dropped, "time": {"day": day_night.day, "time": day_night.time}, "spawn": {"bed": has_bed_spawn, "pos": [bed_spawn.x, bed_spawn.y, bed_spawn.z]}, "forage_regrow": regrow, @@ -330,6 +354,11 @@ func apply_save(s: Dictionary) -> void: player.select(int(p.get("selected", 0))) if p.has("stats"): player.survival.load_data(p.stats) + player.worn = String(p.get("worn", "")) + player.worn_changed.emit() + for d in s.get("dropped", []): + if not ItemDB.get_item(d.get("item", "")).is_empty(): + drop_item(d.item, int(d.get("count", 1)), _array_to_xf(d.xf)) var t: Dictionary = s.get("time", {}) if not t.is_empty(): day_night.day = int(t.get("day", 1)) @@ -434,6 +463,24 @@ func _update_grass_cuts() -> void: xfs.append(p.global_transform) props.set_grass_cuts(xfs, 1.02) +## Leaves `count` of an item lying in the world at `xf` (an arrow stuck in a +## tree, a thrown spear, meat that didn't fit). Saved with the world. +func drop_item(id: String, count: int, xf: Transform3D) -> Node3D: + var pk: Area3D = preload("res://scripts/world/pickup.gd").new() + pk.setup(id, ItemDB.mesh_for(id), ItemDB.item_material(), 0.3) + pk.amount = count + pk.dropped = true + _dropped.add_child(pk) + pk.global_transform = xf + return pk + +## Fires an arrow or thrown spear (Actions validated it). +func launch(id: String, origin: Vector3, vel: Vector3, damage: float, shooter: Node3D) -> Node3D: + var pr: Node3D = preload("res://scripts/creatures/projectile.gd").new() + pr.setup(id, self, origin, vel, damage, shooter) + add_child(pr) + return pr + ## Takes a piece out of the world (dismantled). Anything that loses its ## support collapses, and its item goes back to `refund_to` if there's room. ## Returns how many pieces collapsed. @@ -595,6 +642,67 @@ func _capture_survival(prefix: String, cam: Camera3D) -> void: print("[world] saved %s_menu.png" % prefix) hud._menu.close() await _capture_weather(prefix, cam) + await _capture_creatures(prefix, cam) + +## Wildlife views: a rabbit, a deer and a boar in a meadow, the bow drawn on +## a deer, and a carcass with an arrow in it. +func _capture_creatures(prefix: String, cam: Camera3D) -> void: + weather.force("clear") + weather.snow_cover = 0.0 + weather.ground_wet = 0.0 + day_night.time = 9.5 / 24.0 + day_night.apply(true) + creatures.enabled = false + for c in creatures.get_children(): + c.queue_free() + _respawn() + player.survival.wet = 0.0 + # Open meadow toward the lake, looking the same way as the spawn view. + var site: Vector3 = build.debug_find_site(player.global_position - player.global_basis.z * 16.0 + player.global_basis.x * 10.0, 7.0) + site.y = terrain.sample_height(site.x, site.z) + var hb := Basis(Vector3.UP, player.rotation.y) + var line: Array = [] + for k in [["rabbit", Vector3(-1.6, 0, -4.0), 0.9], ["deer", Vector3(1.4, 0, -8.0), -1.2], ["boar", Vector3(-3.8, 0, -7.5), 2.1]]: + var c: Node3D = creatures.spawn(k[0], site + hb * (k[1] as Vector3)) + c.set_physics_process(false) + c.yaw = player.rotation.y + float(k[2]) + c.rotation.y = c.yaw + line.append(c) + var eye: Vector3 = site + hb * Vector3(0, 0, 1.0) + eye.y = terrain.sample_height(eye.x, eye.z) + 1.7 + player.set_physics_process(false) + player.inventory.set_slot(2, {"id": "spear", "count": 1}) + player.select(2) + _aim_camera(cam, eye, site + hb * Vector3(-0.8, 0.4, -6.0)) + await get_tree().create_timer(1.5).timeout + await RenderingServer.frame_post_draw + get_viewport().get_texture().get_image().save_png("%s_animals.png" % prefix) + print("[world] saved %s_animals.png" % prefix) + # Bow drawn on the deer. + player.inventory.set_slot(3, {"id": "bow", "count": 1}) + player.inventory.set_slot(4, {"id": "arrow", "count": 12}) + player.select(3) + var deer: Node3D = line[1] + _aim_camera(cam, eye, deer.global_position + Vector3(0, 1.1, 0)) + player.hands._drawing = true + player.hands._draw_t = 0.8 + await get_tree().create_timer(0.8).timeout + await RenderingServer.frame_post_draw + get_viewport().get_texture().get_image().save_png("%s_bow.png" % prefix) + print("[world] saved %s_bow.png" % prefix) + player.hands._drawing = false + # A felled deer. + deer.take_hit(100.0, eye, "arrow") + player.select(0) + var back := eye - deer.global_position + back.y = 0.0 + var near: Vector3 = deer.global_position + back.normalized() * 2.8 + near.y = terrain.sample_height(near.x, near.z) + 1.7 + _aim_camera(cam, near, deer.global_position + Vector3(0, 0.3, 0)) + await get_tree().create_timer(1.0).timeout + await RenderingServer.frame_post_draw + get_viewport().get_texture().get_image().save_png("%s_carcass.png" % prefix) + print("[world] saved %s_carcass.png" % prefix) ## Weather views: a snapped-together hut in clear weather, rain, a storm and ## snow, and the view out of its open door in the rain. diff --git a/tests/run_all.sh b/tests/run_all.sh index 7223c03..304ee09 100755 --- a/tests/run_all.sh +++ b/tests/run_all.sh @@ -4,10 +4,14 @@ # Usage: tests/run_all.sh (from the repo root; needs `godot` on PATH) cd "$(dirname "$0")/.." || exit 1 status=0 -for t in test_survival_core test_survival_stats test_save_load test_weather_building test_terrain_edges test_boundary; do +for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_terrain_edges test_boundary; do extra="" [ "$t" = "test_boundary" ] && extra="--fixed-fps 30" - out=$(godot --headless --path . $extra --script "res://tests/$t.gd" 2>&1) + # A suite that waits on a world that failed to load would hang forever, + # so give each one a time limit when `timeout` is available. + limit="" + command -v timeout >/dev/null 2>&1 && limit="timeout 300" + out=$($limit godot --headless --path . $extra --script "res://tests/$t.gd" 2>&1) code=$? result=$(printf '%s\n' "$out" | grep "RESULT:" | tail -n 1) if [ $code -ne 0 ] || printf '%s\n' "$out" | grep -q "SCRIPT ERROR"; then diff --git a/tests/test_creatures_combat.gd b/tests/test_creatures_combat.gd new file mode 100644 index 0000000..3adaff6 --- /dev/null +++ b/tests/test_creatures_combat.gd @@ -0,0 +1,379 @@ +extends SceneTree +## M1d: wildlife (spawning, senses, fleeing, boars charging), melee, thrown +## spears, the bow, butchering, cooking and food buffs, the hide cloak, +## dropped items, and saving all of it. +## Run: godot --headless --path . --script res://tests/test_creatures_combat.gd + +var _fails := 0 +var _finished := false +var A: Node +var S: Node +var DB: Node + +func check(cond: bool, what: String) -> void: + print((" ok " if cond else " FAIL ") + what) + if not cond: + _fails += 1 + +func _initialize() -> void: + _run.call_deferred() + +func _spawn_world() -> Node3D: + var w: Node3D = load("res://scenes/world.tscn").instantiate() + root.add_child(w) + for _i in range(5): + await physics_frame + return w + +func _run() -> void: + A = root.get_node("Actions") + S = root.get_node("Saves") + DB = root.get_node("ItemDB") + S.root_dir = "user://test_worlds" + for w in S.list_worlds(): + S.delete_world(w.id) + var id: String = S.create_world("Hunt", 1337) + S.current_id = id + var world: Node3D = await _spawn_world() + await _body(world) + check(_finished, "test ran to the end (no script errors)") + S.delete_world(id) + DirAccess.remove_absolute(S.root_dir) + print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails]) + quit(1 if _fails > 0 else 0) + +## Runs a creature's logic for `secs` in small steps. +func _sim(c: Node, secs: float) -> void: + var t := 0.0 + while t < secs and is_instance_valid(c): + c.step(0.05) + t += 0.05 + +func _hold(player: Node, id: String) -> void: + var inv: Inventory = player.inventory + for i in range(inv.size()): + var s = inv.get_slot(i) + if s != null and s.id == id: + var tmp = inv.get_slot(0) + inv.set_slot(0, s) + inv.set_slot(i, tmp) + break + player.select(0) + +func _clear(sp: Node) -> void: + for c in sp.get_children(): + c.free() + +func _body(world: Node3D) -> void: + var player: CharacterBody3D = world.get_node("Player") + var sv: Node = player.survival + var inv: Inventory = player.inventory + var terrain: Node3D = world.get_node("Terrain") + var sp: Node = world.creatures + var cam: Camera3D = player.get_node("Camera3D") + player.set_physics_process(false) + sv.set_process(false) + world.weather.force("clear") + world.day_night.time = 0.5 + world.day_night.apply(true) + + print("spawning") + var counts := {} + var bad := 0 + var nearest := INF + for c in sp.alive(): + counts[c.kind] = counts.get(c.kind, 0) + 1 + var p: Vector3 = c.global_position + if not terrain.inside_boundary(p.x, p.z, 0.0) or p.y < terrain.water_level: + bad += 1 + nearest = minf(nearest, p.distance_to(player.global_position)) + check(counts.get("rabbit", 0) >= 4 and counts.get("deer", 0) >= 2 and counts.get("boar", 0) >= 1, + "a new world has wildlife (%s)" % counts) + check(bad == 0, "every animal spawned on dry land inside the island") + check(nearest > 20.0, "none spawned on top of you (nearest %.0f m)" % nearest) + var feathers := 0 + for uid in world.props.pickups: + if world.props.pickups[uid].item_id == "feather": + feathers += 1 + check(feathers >= 50, "feathers lie in the meadows (%d)" % feathers) + sp.enabled = false + _clear(sp) + + # A clear, flat spot to hunt on, with the player facing along +X there. + var site: Vector3 = world.build.debug_find_site(player.global_position + Vector3(10, 0, 0), 6.0) + site.y = terrain.sample_height(site.x, site.z) + player.global_position = site + Vector3(0, 0.02, 0) + player.rotation = Vector3(0, -PI * 0.5, 0) # forward (-Z) turned to +X + cam.rotation.x = 0.0 + var fwd := -player.global_basis.z + player.velocity = Vector3.ZERO + + print("senses") + var rabbit: Node = sp.spawn("rabbit", site + fwd * 4.0) + var r0: float = rabbit.global_position.distance_to(player.global_position) + _sim(rabbit, 2.0) + check(rabbit.state == "flee" and rabbit.global_position.distance_to(player.global_position) > r0 + 4.0, + "a rabbit 4 m away bolts (%s, %.0f m -> %.0f m)" % [rabbit.state, r0, rabbit.global_position.distance_to(player.global_position)]) + rabbit.free() + var deer: Node = sp.spawn("deer", site + fwd * 22.0) + _sim(deer, 1.0) + check(deer.state != "flee", "a deer 22 m away doesn't notice you standing still") + player.velocity = fwd * 8.0 + _sim(deer, 1.0) + check(deer.state == "flee", "but hears you sprinting") + player.velocity = Vector3.ZERO + deer.free() + var boar: Node = sp.spawn("boar", site + fwd * 3.0) + var h0: float = sv.health + _sim(boar, 3.0) + check(boar.state != "charge" and sv.health == h0, "a boar leaves you alone if you leave it alone") + boar.free() + + print("melee") + rabbit = sp.spawn("rabbit", site + fwd * 1.6) + rabbit.set_physics_process(false) + player.select(7) # an empty hotbar slot + inv.set_slot(7, null) + check(not A.melee(player, rabbit) and rabbit.hp == 10.0, "bare hands can't hurt an animal") + check(rabbit.interact_prompt(player).contains("need a weapon"), "and looking at one says what you need") + inv.add("berries", 2) + _hold(player, "berries") + check(not A.melee(player, rabbit) and rabbit.hp == 10.0, "nor can a handful of berries") + inv.add("torch", 1) + _hold(player, "torch") + check(not A.melee(player, rabbit) and rabbit.hp == 10.0, "nor a torch") + inv.add("arrow", 1) + _hold(player, "arrow") + check(not A.melee(player, rabbit) and rabbit.hp == 10.0, "nor an arrow held in your hand") + inv.remove("arrow", 1) + inv.add("stone_axe", 1) + _hold(player, "stone_axe") + check(A.melee(player, rabbit) and rabbit.hp == 4.0, "an axe works, though it's a poor weapon (hp %.0f)" % rabbit.hp) + rabbit.free() + inv.add("club", 1) + _hold(player, "club") + rabbit = sp.spawn("rabbit", site + fwd * 1.6) + rabbit.set_physics_process(false) + await physics_frame + await physics_frame + cam.rotation.x = -0.5 # look down at it + var tgt: Node = player.hands.melee_target() + check(tgt == rabbit, "the club's sweep finds a rabbit at your feet") + cam.rotation.x = 0.0 + var st0: float = sv.stamina + check(A.melee(player, rabbit) and rabbit.is_dead(), "one club blow kills a rabbit") + check(sv.stamina < st0, "swinging costs stamina") + check(rabbit.is_in_group("carcass") and rabbit.collision_layer == 2, "it leaves a carcass you can butcher") + check(not A.melee(player, rabbit), "can't keep hitting a carcass") + var meat0 := inv.count("raw_meat") + check(A.butcher(player, rabbit) and inv.count("raw_meat") == meat0 + 1, "butchering a rabbit gives meat") + await process_frame + check(not is_instance_valid(rabbit), "and the carcass is gone") + var far_rabbit: Node = sp.spawn("rabbit", site + fwd * 9.0) + far_rabbit.set_physics_process(false) + check(not A.melee(player, far_rabbit), "can't club something 9 m away") + far_rabbit.free() + + print("boar fight") + boar = sp.spawn("boar", site + fwd * 2.5) + boar.set_physics_process(false) + sv.stamina = 100.0 + A.melee(player, boar) + check(boar.state == "charge" and not boar.is_dead(), "hit a boar and it charges (hp %.0f)" % boar.hp) + h0 = sv.health + _sim(boar, 3.0) + check(sv.health <= h0 - 13.0, "and gores you (health %.0f -> %.0f)" % [h0, sv.health]) + var hits := 0 + while not boar.is_dead() and hits < 12: + sv.stamina = 100.0 + boar.global_position = site + fwd * 2.0 + A.melee(player, boar) + hits += 1 + check(boar.is_dead() and hits >= 3, "a boar takes several blows (%d more)" % hits) + var hide0 := inv.count("hide") + player.global_position = boar.global_position - fwd * 1.5 + A.butcher(player, boar) + check(inv.count("hide") > hide0 and inv.count("raw_meat") >= meat0 + 4, "a boar gives hide and plenty of meat") + sv.health = 100.0 + player.global_position = site + Vector3(0, 0.02, 0) + + print("spear") + inv.add("spear", 1) + _hold(player, "spear") + deer = sp.spawn("deer", site + fwd * 9.0) + deer.set_physics_process(false) + await physics_frame + var a: Array = player.hands._aim() + var aim_at: Vector3 = deer.global_position + Vector3(0, 1.0, 0) + sv.stamina = 100.0 + # A thrown spear drops about 0.6 m over 9 m: aim a little high, as a player would. + check(A.throw_spear(player, 0, a[0], aim_at + Vector3(0, 0.6, 0) - (a[0] as Vector3), world) and inv.count("spear") == 0, "threw the spear") + for _i in range(40): + await physics_frame + check(deer.hp < 40.0, "the spear hits the deer (hp %.0f)" % deer.hp) + var spear_drop: Node = null + for d in world.get_node("Dropped").get_children(): + if d.item_id == "spear": + spear_drop = d + check(spear_drop != null and spear_drop.global_position.distance_to(deer.global_position) < 2.0, "and lands at its feet") + player.global_position = spear_drop.global_position + Vector3(0.5, 0, 0) + check(A.pickup(player, spear_drop) and inv.count("spear") == 1, "pick the spear up again") + player.global_position = site + Vector3(0, 0.02, 0) + + print("bow") + inv.add("bow", 1) + _hold(player, "bow") + check(not A.can_draw(player), "no arrows, no shot") + inv.add("arrow", 3) + check(A.can_draw(player), "with arrows you can draw") + deer.hp = 40.0 + a = player.hands._aim() + A.shoot(player, a[0], aim_at - (a[0] as Vector3) + Vector3(0, 0.15, 0), 1.0, world) + for _i in range(30): + await physics_frame + check(inv.count("arrow") == 2, "a shot uses one arrow") + check(deer.hp <= 40.0 - 25.0, "a full draw hits hard (hp %.0f)" % deer.hp) + A.shoot(player, a[0], aim_at - (a[0] as Vector3) + Vector3(0, 0.15, 0), 1.0, world) + for _i in range(30): + await physics_frame + check(deer.is_dead(), "two full-draw arrows bring down a deer") + player.global_position = deer.global_position - fwd * 1.5 + var arrows0 := inv.count("arrow") + var hide1 := inv.count("hide") + A.butcher(player, deer) + check(inv.count("arrow") == arrows0 + 2 and inv.count("hide") > hide1, "butchering gives hide and your arrows back") + player.global_position = site + Vector3(0, 0.02, 0) + a = player.hands._aim() + A.shoot(player, a[0], fwd + Vector3(0, -0.25, 0), 0.6, world) + for _i in range(40): + await physics_frame + var stuck: Node = null + for d in world.get_node("Dropped").get_children(): + if d.item_id == "arrow": + stuck = d + check(stuck != null and stuck.global_position.distance_to(site) < 15.0, "an arrow that misses sticks in the ground") + world.weather.force("clear") + + print("cooking and buffs") + var fire: Node = world.spawn_piece(DB.get_item("campfire"), Transform3D(Basis(), site - fwd * 2.0 + Vector3(0, terrain.sample_height(site.x, site.z) - site.y, 0))) + player.global_position = site + Vector3(0, 0.02, 0) + check(A.craft(player, "cooked_meat") and inv.count("cooked_meat") == 1, "cooked meat at the campfire") + sv.health = 2.0 + inv.add("raw_meat", 1) + _hold(player, "raw_meat") + sv.hunger = 50.0 + A.eat(player, 0) + check(sv.health >= 1.0 and sv.health <= 2.0, "raw meat can hurt, but never kills (health %.0f)" % sv.health) + sv.health = 60.0 + _hold(player, "cooked_meat") + A.eat(player, 0) + check(sv.buffs.has("well_fed") and sv.buff_mult("stamina_regen") > 1.2, "cooked meat makes you Well fed") + sv._update_statuses() + check(sv.statuses.any(func(s): return s.begins_with("Well fed")), "and the HUD shows it (%s)" % [sv.statuses]) + # Healing with the buff vs without (fed and warm, no recent hits). + sv.hunger = 90.0 + sv.warmth = 70.0 + sv._hurt_cd = 0.0 + sv.health = 50.0 + sv.step(10.0) + var fed_gain: float = sv.health - 50.0 + sv.buffs.clear() + sv.health = 50.0 + sv.step(10.0) + var plain_gain: float = sv.health - 50.0 + check(fed_gain > plain_gain * 1.8, "Well fed heals faster (%.1f vs %.1f per 10 s)" % [fed_gain, plain_gain]) + sv.damage(5.0, site) + var hh: float = sv.health + sv.step(3.0) + check(sv.health <= hh, "no natural healing just after a hit") + world.remove_piece(fire) + + print("cloak") + var bench: Node = world.spawn_piece(DB.get_item("workbench"), Transform3D(Basis(), site + fwd * -3.0 + Vector3(0, -0.02, 0))) + inv.add("hide", 4) + inv.add("fiber", 4) + check(A.craft(player, "hide_cloak") and inv.count("hide_cloak") == 1, "a hide cloak from 4 hide and 4 fiber") + world.remove_piece(bench) + world.day_night.time = 0.0 + world.day_night.apply(true) + sv.warmth = 50.0 + for _i in range(300): + sv.step(1.0) + var bare: float = sv.warmth + _hold(player, "hide_cloak") + check(A.wear(player, 0) and player.worn == "hide_cloak" and inv.count("hide_cloak") == 0, "right-click wears it") + sv.warmth = 50.0 + for _i in range(300): + sv.step(1.0) + check(sv.warmth > bare + 12.0, "the cloak keeps you warmer at night (%.0f vs %.0f)" % [sv.warmth, bare]) + check(A.take_off(player) and player.worn == "" and inv.count("hide_cloak") == 1, "take it off again") + _hold(player, "hide_cloak") + A.wear(player, 0) + world.day_night.time = 0.5 + world.day_night.apply(true) + inv.add("cooked_meat", 1) + _hold(player, "cooked_meat") + sv.hunger = 40.0 + A.eat(player, 0) + + print("spawner") + for k in sp._cooldown: + sp._cooldown[k] = 0.0 # (the kills above put every kind on its respawn timer) + sp.enabled = true + sp._check = 0.0 + sp._process(0.1) + check(sp.alive().size() >= 1, "the spawner tops the wildlife back up") + var stray: Node = sp.spawn("deer", player.global_position + Vector3(0, 0, 0)) + stray.position = player.global_position + Vector3(110, 0, 0) + sp._check = 0.0 + sp._process(0.1) + check(stray.is_queued_for_deletion(), "animals that wander far away despawn") + sp.enabled = false + await process_frame + _clear(sp) + # Walk in from the lake's east side to the last dry spot before the water, + # then check the deer refuses to step in. + var lake: Vector2 = terrain.lake_center + var swimmer: Node = sp.spawn("deer", Vector3(lake.x + terrain.lake_radius + 8.0, 0, lake.y)) + var edge := Vector3.INF + var x: float = lake.x + terrain.lake_radius + 8.0 + while x > lake.x: + if terrain.sample_height(x - 1.0, lake.y) < terrain.water_level + 0.1: + edge = Vector3(x, terrain.sample_height(x, lake.y), lake.y) + break + x -= 0.25 + swimmer.position = edge if edge != Vector3.INF else swimmer.position + check(edge != Vector3.INF and swimmer._blocked(Vector3(-1, 0, 0)), "animals won't walk into the lake") + check(not swimmer._blocked(Vector3(1, 0, 0)), "but will walk away from it") + swimmer.free() + + print("save + load") + var buff_left: float = sv.buffs.get("well_fed", {}).get("left", 0.0) + var stuck_pos: Vector3 = stuck.global_position + world.save_now() + world.queue_free() + await process_frame + await process_frame + world = await _spawn_world() + player = world.get_node("Player") + sv = player.survival + check(player.worn == "hide_cloak", "still wearing the cloak after loading") + check(sv.buffs.has("well_fed") and absf(float(sv.buffs.well_fed.left) - buff_left) < 2.0, "Well fed survives a reload") + var stuck2: Node = null + for d in world.get_node("Dropped").get_children(): + if d.item_id == "arrow" and d.global_position.distance_to(stuck_pos) < 0.05: + stuck2 = d + check(stuck2 != null, "the stuck arrow is still there") + check(world.creatures.alive().size() >= 5, "a loaded world has wildlife again (%d)" % world.creatures.alive().size()) + + print("death") + player.set_physics_process(false) + sv.set_process(false) + sv.damage(1000.0, player.global_position) + var grave: Node = null + for p in world.get_node("Pieces").get_children(): + if p.item_id == "grave": + grave = p + check(player.worn == "" and grave != null and grave.inventory.count("hide_cloak") == 1, "dying puts your cloak in the grave") + world.queue_free() + _finished = true