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",
"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
}
}
]
}

View File

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

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

View File

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

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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)"],

View File

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

View File

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

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_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.

View File

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

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