M1d: creatures, hunting, weapons, food buffs and the hide cloak

- Rabbits, deer and boars with procedural meshes and animated legs, a
  state machine (idle/wander/flee/charge/dead) and heightmap steering that
  avoids water, cliffs, the world edge, trees and buildings
- Spawner keeps a habitat-based budget around the player (6/4/3)
- Melee needs a weapon or tool (club, spear, axe, pickaxe); box sweep hits
- Wooden spear (thrust or throw) and crude bow with draw-scaled arrows;
  projectiles stick in the world as saved pickups
- Carcasses: E to butcher for meat, hide and embedded arrows
- Boars charge and gore when hit; hurt flash and a brief healing pause
- Cooked meat gives Well fed (faster stamina and healing); buffs saved
- Hide cloak: wear for warmth, saved, goes in the grave on death
- Feathers as meadow pickups (appended, existing save ids unchanged)
- New test_creatures_combat suite; run_all.sh gets a per-suite time limit
This commit is contained in:
Paul Bailey
2026-11-04 10:55:44 +00:00
parent fed96ef118
commit 67bf85523d
22 changed files with 1782 additions and 33 deletions

View File

@@ -65,12 +65,13 @@
"name": "Wooden Club", "name": "Wooden Club",
"category": "weapon", "category": "weapon",
"stack": 1, "stack": 1,
"description": "Crude, heavy, effective. (Creatures arrive in M1d.)", "description": "Crude, heavy, effective. One blow fells a rabbit.",
"mesh": "club", "mesh": "club",
"weapon": { "weapon": {
"damage": 12, "damage": 12,
"reach": 2.2, "reach": 2.4,
"stamina": 8 "stamina": 8,
"cooldown": 0.55
} }
}, },
{ {
@@ -266,6 +267,113 @@
2.1 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
}
} }
] ]
} }

View File

@@ -134,6 +134,57 @@
"wood": 8, "wood": 8,
"fiber": 6 "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
}
} }
] ]
} }

View File

@@ -32,7 +32,8 @@ Detailed plan in `docs/SURVIVAL_DESIGN.md` (phases M1a–M1d).
- [x] Weather (rain, storm, snow), wet/shelter *(M1c)* - [x] Weather (rain, storm, snow), wet/shelter *(M1c)*
- [x] Snap-together building: floor/wall/door/roof/gable, resource cost, support + collapse, dismantling *(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)* - [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. - **Done when:** gather → craft → build a tiny shelter, single-player.
## M2 — Multiplayer · ~2–4 months ## M2 — Multiplayer · ~2–4 months

View File

@@ -20,6 +20,8 @@ tests/run_all.sh
``` ```
Or one at a time: Or one at a time:
```bash ```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 # Weather, shelter, snap building, support, doors, beds, regrowth
godot --headless --path . --script res://tests/test_weather_building.gd godot --headless --path . --script res://tests/test_weather_building.gd
# Boundary: tries to walk/jump out of the valley in 24 directions # Boundary: tries to walk/jump out of the valley in 24 directions

View File

@@ -152,19 +152,57 @@ Built in M1b (`scripts/player/survival.gd`), current numbers:
strength are global shader uniforms. Rain, wind and thunder sounds are strength are global shader uniforms. Rain, wind and thunder sounds are
synthesised from filtered noise, and rain is muffled under a roof. synthesised from filtered noise, and rain is muffled under a roof.
### 2.8 Creatures & hunting *(M1d)* ### 2.8 Creatures & hunting *(built in M1d)*
- **Rabbit**: skittish, fast, gives a little meat. | Animal | HP | Run speed | Notices you | Behaviour | Butchering gives |
- **Deer**: flees on sight or sound, gives meat and hide. |---|---|---|---|---|---|
- **Boar**: neutral; charges if hit; gives meat and hide. | Rabbit | 10 | 7 m/s, zig-zags | 7 m (12 m if you sprint) | flees | 1 raw meat |
- *(later)* **Wolf**: night predator, hunts in packs. | Deer | 40 | 9 m/s | 16 m (30 m if you sprint) | flees, outruns you | 2-3 meat, 1-2 hide |
- Simple state machines (idle / wander / flee / attack) on navigation-free | 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 |
steering over the heightmap. Spawned around the player within budget.
### 2.9 Weapons & combat *(M1d)* - *(later)* **Wolf**: night predator, hunts in packs.
- Club (hand), wooden spear (thrust; can be thrown), bow + arrows (workbench; - `scripts/creatures/creature.gd` runs each animal's state machine (idle,
needs feathers and flint). wander, flee, charge, dead). Steering is over the heightmap with no navmesh:
- A melee swing is a short shape cast in front of the camera. It costs stamina a probe ahead turns the animal away from water, steep drops, the world edge
and has a cooldown. Damage numbers come from item data. 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)* ### 2.10 Saving & worlds *(built)*
- **One continuous world per save slot.** The title screen offers Continue - **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 | | **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 | | **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 | | **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) ## 5. Open decisions (current defaults in bold)
- Thirst stat? **No**, to keep the stats readable (Valheim has none). - Thirst stat? **No**, to keep the stats readable (Valheim has none).

View File

@@ -191,6 +191,134 @@ func use_bed(player: Node3D, bed: Node3D) -> bool:
world.sleep(player) world.sleep(player)
return true 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: func forage(player: Node3D, bush: Node3D) -> bool:
if not is_instance_valid(bush) or not _in_reach(player, bush) or not bush.ripe(): if not is_instance_valid(bush) or not _in_reach(player, bush) or not bush.ripe():
return false return false

View File

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

View File

@@ -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}

View File

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

View File

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

View File

@@ -57,6 +57,14 @@ static func build(key: String) -> ArrayMesh:
"wood_roof": m = _roof() "wood_roof": m = _roof()
"wood_gable": m = _gable() "wood_gable": m = _gable()
"bed": m = _bed() "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": m = _berry_bush(true)
"berry_bush_bare": m = _berry_bush(false) "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.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) _box(st, Vector3(0, 0.47, 0.25), Vector3(0.9, 0.08, 1.3), FUR)
return P._commit(st) 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)

View File

@@ -28,6 +28,10 @@ var _ghost_yaw := 0.0
var _place_xf := Transform3D() var _place_xf := Transform3D()
var _place_ok := false var _place_ok := false
var _place_reason := "" var _place_reason := ""
var _rest_default := Transform3D()
var _rest_bow := Transform3D()
var _drawing := false
var _draw_t := 0.0
var _prompt := "" var _prompt := ""
var _held_shown := "~" var _held_shown := "~"
var _torch_light: OmniLight3D var _torch_light: OmniLight3D
@@ -37,7 +41,7 @@ var _t := 0.0
func _ready() -> void: func _ready() -> void:
_ray = RayCast3D.new() _ray = RayCast3D.new()
_ray.target_position = Vector3(0, 0, -REACH) _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.collide_with_areas = true
_ray.add_exception(player) _ray.add_exception(player)
add_child(_ray) add_child(_ray)
@@ -47,6 +51,8 @@ func _ready() -> void:
_view.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF _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_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 _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) add_child(_view)
_ghost_ok_mat = _ghost_material(Color(0.3, 1.0, 0.4, 0.45)) _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 _held_shown = id
var def := _held_def() var def := _held_def()
_view.visible = not id.is_empty() and not def.has("place") _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: if _view.visible:
_view.mesh = ItemDB.mesh_for(id) _view.mesh = ItemDB.mesh_for(id)
var s := 0.85 if def.category in ["tool", "weapon"] else 0.6 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) _set_prompt(p)
elif def.has("food"): elif def.has("food"):
_set_prompt("[RMB] Eat %s" % def.name) _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: else:
_set_prompt("") _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: func _update_ghost(def: Dictionary) -> void:
var cam := get_parent() as Camera3D var cam := get_parent() as Camera3D
@@ -197,9 +222,17 @@ func _unhandled_input(event: InputEvent) -> void:
player.select(player.selected - 1) player.select(player.selected - 1)
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN: elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
player.select(player.selected + 1) 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 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) 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"): elif event.is_action_pressed("rotate_piece"):
# Building pieces turn in quarter turns (they snap to a grid). # 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) _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()) Actions.place(player, player.selected, _place_xf, player.get_parent())
else: else:
Actions.notify.emit("Can't place that here") Actions.notify.emit("Can't place that here")
elif _held_def().has("bow"):
_start_draw()
else: else:
swing() 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. ## Swings the held item (or fist). The hit is applied at the peak of the swing.
func swing() -> void: func swing() -> void:
if _swing_cd > 0.0: if _swing_cd > 0.0:
return return
_swing_cd = SWING_TIME _swing_cd = float(Actions.melee_stats(player).cooldown)
var tw := create_tween() 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 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)) 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) tw.tween_property(_view, "transform", _view_rest, 0.25).set_trans(Tween.TRANS_SINE)
func _apply_hit() -> void: 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() _ray.force_raycast_update()
if not _ray.is_colliding(): if not _ray.is_colliding():
return return
var c := _ray.get_collider() var c := _ray.get_collider()
if c != null and c.is_in_group("harvestable"): if c != null and c.is_in_group("harvestable"):
Actions.harvest_hit(player, c, _ray.get_collision_point()) 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

View File

@@ -27,6 +27,8 @@ var selected := 0
var ui_open := false ## set by the inventory screen; freezes look/move var ui_open := false ## set by the inventory screen; freezes look/move
var hands: Node3D var hands: Node3D
var survival: Node ## health / stamina / hunger / warmth (survival.gd) 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) var _gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity", 9.8)

View File

@@ -35,6 +35,12 @@ var sheltered := false ## roof and walls (the weather barely reaches you)
var rested := 0.0 ## seconds of Rested left var rested := 0.0 ## seconds of Rested left
var _shelter_left := 0.0 var _shelter_left := 0.0
var _rest_build := 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 player: CharacterBody3D
var world: Node3D var world: Node3D
@@ -65,9 +71,10 @@ func step(dt: float) -> void:
else: else:
_rest_build = 0.0 _rest_build = 0.0
rested = maxf(rested - dt, 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: 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 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) target = clampf(target, 0.0, MAX)
# Warm up faster than you cool down: getting to a fire should feel good. # 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: if _stamina_wait > 0.0:
_stamina_wait -= dt _stamina_wait -= dt
else: 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 var dmg := 0.0
if hunger <= 0.0: if hunger <= 0.0:
@@ -87,8 +100,8 @@ func step(dt: float) -> void:
dmg += 0.5 dmg += 0.5
if dmg > 0.0: if dmg > 0.0:
health = maxf(health - dmg * dt, 0.0) health = maxf(health - dmg * dt, 0.0)
elif hunger > 40.0 and warmth > 35.0: elif hunger > 40.0 and warmth > 35.0 and _hurt_cd <= 0.0:
health = minf(health + 0.5 * dt, MAX) health = minf(health + 0.5 * buff_mult("health_regen") * dt, MAX)
_update_statuses() _update_statuses()
changed.emit() changed.emit()
@@ -114,6 +127,8 @@ func _update_statuses() -> void:
statuses.append("Sheltered") statuses.append("Sheltered")
if rested > 0.0: if rested > 0.0:
statuses.append("Rested") 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 ## Spends stamina for an action. Returns false (and spends nothing) if you're
## too tired. Continuous costs (sprinting) pass `partial = true`. ## 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: func eat(food: Dictionary) -> void:
hunger = minf(hunger + float(food.get("hunger", 0)), MAX) 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() changed.emit()
func soak() -> void: func soak() -> void:
@@ -149,10 +167,38 @@ func reset_after_death() -> void:
hunger = 50.0 hunger = 50.0
warmth = 60.0 warmth = 60.0
wet = 0.0 wet = 0.0
buffs.clear()
changed.emit() 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: 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: func load_data(d: Dictionary) -> void:
health = float(d.get("health", health)) health = float(d.get("health", health))
@@ -161,5 +207,6 @@ func load_data(d: Dictionary) -> void:
warmth = float(d.get("warmth", warmth)) warmth = float(d.get("warmth", warmth))
wet = float(d.get("wet", 0.0)) wet = float(d.get("wet", 0.0))
rested = float(d.get("rested", 0.0)) rested = float(d.get("rested", 0.0))
buffs = (d.get("buffs", {}) as Dictionary).duplicate(true)
dead = false dead = false
changed.emit() changed.emit()

View File

@@ -18,6 +18,13 @@ var _menu: Control
var _bars: Dictionary = {} var _bars: Dictionary = {}
var _status: Label var _status: Label
var _clock: 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: func bind(p: CharacterBody3D) -> void:
player = p player = p
@@ -93,6 +100,13 @@ func bind(p: CharacterBody3D) -> void:
player.selected_changed.connect(_refresh) player.selected_changed.connect(_refresh)
player.inventory_requested.connect(_on_inventory_requested) player.inventory_requested.connect(_on_inventory_requested)
player.hands.prompt_changed.connect(func(t): _prompt.text = t) 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) Actions.notify.connect(toast)
ItemDB.icons_ready.connect(_refresh) ItemDB.icons_ready.connect(_refresh)
_refresh() _refresh()

View File

@@ -21,6 +21,8 @@ var _chest_grid: GridContainer
var _chest_title: Label var _chest_title: Label
var _recipe_list: VBoxContainer var _recipe_list: VBoxContainer
var _station_label: Label var _station_label: Label
var _worn_label: Label
var _worn_btn: Button
var _cursor = null ## stack held on the mouse var _cursor = null ## stack held on the mouse
var _cursor_view: Control var _cursor_view: Control
var _cursor_icon: TextureRect var _cursor_icon: TextureRect
@@ -73,6 +75,19 @@ func bind(p: CharacterBody3D) -> void:
pass pass
pv.add_child(grid) 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)) 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) pp.add_child(pv)
row.add_child(pp) row.add_child(pp)
@@ -203,6 +218,8 @@ func _refresh() -> void:
if _cursor != null: if _cursor != null:
_cursor_icon.texture = ItemDB.icon(_cursor.id) _cursor_icon.texture = ItemDB.icon(_cursor.id)
_cursor_count.text = str(_cursor.count) if _cursor.count > 1 else "" _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() _rebuild_recipes()
func _rebuild_recipes() -> void: func _rebuild_recipes() -> void:

View File

@@ -8,7 +8,8 @@ const UITheme = preload("res://scripts/ui/theme.gd")
const CONTROLS := [ const CONTROLS := [
["W A S D", "Move"], ["Shift", "Sprint"], ["Space", "Jump / swim up"], ["W A S D", "Move"], ["Shift", "Sprint"], ["Space", "Jump / swim up"],
["Mouse", "Look"], ["E", "Interact / pick up / add fuel"], ["Left click", "Use tool / place"], ["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"], ["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)"], ["E on a bed", "Set spawn point / sleep (night, under a roof)"],
["1 - 8, wheel", "Hotbar"], ["Tab", "Inventory & crafting (shift-click Craft for 5)"], ["1 - 8, wheel", "Hotbar"], ["Tab", "Inventory & crafting (shift-click Craft for 5)"],

View File

@@ -25,18 +25,24 @@ func setup(id: String, mesh: Mesh, material: Material, radius: float) -> void:
add_child(cs) add_child(cs)
func interact_prompt(_player: Node) -> String: 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: func interact(player: Node) -> void:
Actions.pickup(player, self) Actions.pickup(player, self)
signal taken_now(pickup: Node) 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). ## Picked up: hidden (not freed) so it can reappear later (props.gd regrowth).
## Dropped items just go.
func collected() -> void: func collected() -> void:
if taken: if taken:
return return
taken = true taken = true
if dropped:
queue_free()
return
visible = false visible = false
collision_layer = 0 collision_layer = 0
taken_now.emit(self) taken_now.emit(self)

View File

@@ -581,8 +581,8 @@ func _scatter_pickups() -> void:
var rng := RandomNumberGenerator.new() var rng := RandomNumberGenerator.new()
rng.seed = world_seed + 6 rng.seed = world_seed + 6
# Order matters: new kinds go at the END so existing saves keep their ids. # 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 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} var radius: Dictionary = {"stick": 0.35, "stone": 0.25, "flint": 0.25, "mushroom": 0.25, "feather": 0.25}
for id: String in want: for id: String in want:
var mesh := ItemDB.mesh_for(id) var mesh := ItemDB.mesh_for(id)
var placed := 0 var placed := 0
@@ -608,6 +608,8 @@ func _scatter_pickups() -> void:
chance = 0.06 + shore * 0.9 + (1.0 - n.y) * 2.0 chance = 0.06 + shore * 0.9 + (1.0 - n.y) * 2.0
"mushroom": "mushroom":
chance = _forest(x, z) * 0.7 # deep forest only 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. # A few right by the spawn so the first craft is quick.
if Vector2(x - _spawn.x, z - _spawn.z).length() < 18.0: if Vector2(x - _spawn.x, z - _spawn.z).length() < 18.0:
chance += 0.5 chance += 0.5

View File

@@ -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: Node ## weather.gd: the weather model
var weather_fx: Node3D ## weather_fx.gd: rain/snow particles, sound var weather_fx: Node3D ## weather_fx.gd: rain/snow particles, sound
var build: Node ## build_system.gd: snapping, support, shelter 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 has_bed_spawn := false
var bed_spawn := Vector3.ZERO ## position of the bed you respawn at var bed_spawn := Vector3.ZERO ## position of the bed you respawn at
var _loading := false ## applying a save: defer support recompute var _loading := false ## applying a save: defer support recompute
@@ -68,6 +70,13 @@ func _ready() -> void:
build.name = "Build" build.name = "Build"
add_child(build) add_child(build)
build.setup(self) 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": if not menu_mode and DisplayServer.get_name() != "headless":
weather_fx = preload("res://scripts/world/weather_fx.gd").new() weather_fx = preload("res://scripts/world/weather_fx.gd").new()
weather_fx.name = "WeatherFX" weather_fx.name = "WeatherFX"
@@ -131,6 +140,10 @@ func _on_player_died() -> void:
if pos.y < terrain.water_level: if pos.y < terrain.water_level:
# Drowned or died in the lake: put the grave on the nearest dry shore. # Drowned or died in the lake: put the grave on the nearest dry shore.
pos = terrain.find_spawn(Vector2(pos.x, pos.z)) 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 var has_items := false
for st in player.inventory.slots: for st in player.inventory.slots:
if st != null: if st != null:
@@ -225,6 +238,10 @@ func _on_terrain_ready() -> void:
print("[world] loaded '%s'" % Saves.current_id) print("[world] loaded '%s'" % Saves.current_id)
player.set_physics_process(true) player.set_physics_process(true)
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() _maybe_capture()
func _process(delta: float) -> void: func _process(delta: float) -> void:
@@ -291,6 +308,11 @@ func collect_save() -> Dictionary:
var pk = props.pickups[uid] var pk = props.pickups[uid]
if not is_instance_valid(pk) or pk.taken: if not is_instance_valid(pk) or pk.taken:
taken.append(uid) 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 = [] var pieces: Array = []
for piece in _pieces.get_children(): for piece in _pieces.get_children():
if piece.is_queued_for_deletion(): if piece.is_queued_for_deletion():
@@ -309,7 +331,9 @@ func collect_save() -> Dictionary:
"inventory": player.inventory.to_data(), "inventory": player.inventory.to_data(),
"selected": player.selected, "selected": player.selected,
"stats": player.survival.to_data(), "stats": player.survival.to_data(),
"worn": player.worn,
}, },
"dropped": dropped,
"time": {"day": day_night.day, "time": day_night.time}, "time": {"day": day_night.day, "time": day_night.time},
"spawn": {"bed": has_bed_spawn, "pos": [bed_spawn.x, bed_spawn.y, bed_spawn.z]}, "spawn": {"bed": has_bed_spawn, "pos": [bed_spawn.x, bed_spawn.y, bed_spawn.z]},
"forage_regrow": regrow, "forage_regrow": regrow,
@@ -330,6 +354,11 @@ func apply_save(s: Dictionary) -> void:
player.select(int(p.get("selected", 0))) player.select(int(p.get("selected", 0)))
if p.has("stats"): if p.has("stats"):
player.survival.load_data(p.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", {}) var t: Dictionary = s.get("time", {})
if not t.is_empty(): if not t.is_empty():
day_night.day = int(t.get("day", 1)) day_night.day = int(t.get("day", 1))
@@ -434,6 +463,24 @@ func _update_grass_cuts() -> void:
xfs.append(p.global_transform) xfs.append(p.global_transform)
props.set_grass_cuts(xfs, 1.02) 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 ## 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. ## support collapses, and its item goes back to `refund_to` if there's room.
## Returns how many pieces collapsed. ## Returns how many pieces collapsed.
@@ -595,6 +642,67 @@ func _capture_survival(prefix: String, cam: Camera3D) -> void:
print("[world] saved %s_menu.png" % prefix) print("[world] saved %s_menu.png" % prefix)
hud._menu.close() hud._menu.close()
await _capture_weather(prefix, cam) 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 ## 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. ## snow, and the view out of its open door in the rain.

View File

@@ -4,10 +4,14 @@
# Usage: tests/run_all.sh (from the repo root; needs `godot` on PATH) # Usage: tests/run_all.sh (from the repo root; needs `godot` on PATH)
cd "$(dirname "$0")/.." || exit 1 cd "$(dirname "$0")/.." || exit 1
status=0 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="" extra=""
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30" [ "$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=$? code=$?
result=$(printf '%s\n' "$out" | grep "RESULT:" | tail -n 1) result=$(printf '%s\n' "$out" | grep "RESULT:" | tail -n 1)
if [ $code -ne 0 ] || printf '%s\n' "$out" | grep -q "SCRIPT ERROR"; then if [ $code -ne 0 ] || printf '%s\n' "$out" | grep -q "SCRIPT ERROR"; then

View File

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