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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user