Ruined
This commit is contained in:
+11
@@ -0,0 +1,11 @@
|
||||
on: [push, pull_request]
|
||||
name: build
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
- name: lint
|
||||
uses: Roang-zero1/factorio-mod-luacheck@master
|
||||
with:
|
||||
luacheckrc_url: ""
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
max_line_length = 120
|
||||
|
||||
globals = {
|
||||
"minetest",
|
||||
"VoxelArea",
|
||||
"creatura",
|
||||
}
|
||||
|
||||
read_globals = {
|
||||
"vector",
|
||||
"ItemStack",
|
||||
table = {fields = {"copy"}}
|
||||
}
|
||||
|
||||
ignore = {"212/self", "212/this"}
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 ElCeejo
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
# Bundled third-party dependency
|
||||
|
||||
This directory contains Creatura 0.2.10 from Luanti ContentDB release 34130.
|
||||
It is bundled as the wildlife AI/pathfinding/flocking API dependency for Last Hearth.
|
||||
|
||||
- ContentDB: https://content.luanti.org/packages/ElCeejo/creatura/
|
||||
- Upstream source: https://github.com/ElCeejo/creatura/tree/4eb507cf2433f0787691f560842deea79a1666f4
|
||||
- License: MIT; original LICENSE is preserved alongside this notice.
|
||||
- ContentDB archive: https://cdn.content.luanti.org/uploads/73b01eb441.zip
|
||||
- Archive SHA-256: `f384a34e0ed6e03c52b6d6591566e6546a26776c5f7e6e7dce279e7993a4251e`
|
||||
|
||||
The archive hash was verified before extraction. No Animalia code, models,
|
||||
textures, or sound files are included by this dependency.
|
||||
Executable
+557
@@ -0,0 +1,557 @@
|
||||
--------------
|
||||
-- Creatura --
|
||||
--------------
|
||||
|
||||
creatura.api = {}
|
||||
|
||||
-- Math --
|
||||
|
||||
local abs = math.abs
|
||||
local floor = math.floor
|
||||
local random = math.random
|
||||
|
||||
local function clamp(val, min_n, max_n)
|
||||
if val < min_n then
|
||||
val = min_n
|
||||
elseif max_n < val then
|
||||
val = max_n
|
||||
end
|
||||
return val
|
||||
end
|
||||
|
||||
local vec_dist = vector.distance
|
||||
|
||||
local function vec_raise(v, n)
|
||||
if not v then return end
|
||||
return {x = v.x, y = v.y + n, z = v.z}
|
||||
end
|
||||
|
||||
---------------
|
||||
-- Local API --
|
||||
---------------
|
||||
|
||||
local function contains_val(tbl, val)
|
||||
for _, v in pairs(tbl) do
|
||||
if v == val then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
----------------------------
|
||||
-- Registration Functions --
|
||||
----------------------------
|
||||
|
||||
creatura.registered_movement_methods = {}
|
||||
|
||||
function creatura.register_movement_method(name, func)
|
||||
creatura.registered_movement_methods[name] = func
|
||||
end
|
||||
|
||||
creatura.registered_utilities = {}
|
||||
|
||||
function creatura.register_utility(name, func)
|
||||
creatura.registered_utilities[name] = func
|
||||
end
|
||||
|
||||
---------------
|
||||
-- Utilities --
|
||||
---------------
|
||||
|
||||
function creatura.is_valid(mob)
|
||||
if not mob then return false end
|
||||
if type(mob) == "table" then mob = mob.object end
|
||||
if type(mob) == "userdata" then
|
||||
if mob:is_player() then
|
||||
if mob:get_look_horizontal() then return mob end
|
||||
else
|
||||
if mob:get_yaw() then return mob end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function creatura.is_alive(mob)
|
||||
if not creatura.is_valid(mob) then
|
||||
return false
|
||||
end
|
||||
if type(mob) == "table" then
|
||||
return (mob.hp or mob.health or 0) > 0
|
||||
end
|
||||
if mob:is_player() then
|
||||
return mob:get_hp() > 0
|
||||
else
|
||||
local ent = mob:get_luaentity()
|
||||
return ent and (ent.hp or ent.health or 0) > 0
|
||||
end
|
||||
end
|
||||
|
||||
------------------------
|
||||
-- Environment access --
|
||||
------------------------
|
||||
|
||||
local default_node_def = {walkable = true} -- both ignore and unknown nodes are walkable
|
||||
|
||||
function creatura.get_node_height_from_def(name)
|
||||
local def = minetest.registered_nodes[name] or default_node_def
|
||||
if not def then return 0.5 end
|
||||
if def.walkable then
|
||||
if def.drawtype == "nodebox" then
|
||||
if def.node_box
|
||||
and def.node_box.type == "fixed" then
|
||||
if type(def.node_box.fixed[1]) == "number" then
|
||||
return 0.5 + def.node_box.fixed[5]
|
||||
elseif type(def.node_box.fixed[1]) == "table" then
|
||||
return 0.5 + def.node_box.fixed[1][5]
|
||||
else
|
||||
return 1
|
||||
end
|
||||
else
|
||||
return 1
|
||||
end
|
||||
else
|
||||
return 1
|
||||
end
|
||||
else
|
||||
return 1
|
||||
end
|
||||
end
|
||||
|
||||
local get_node = minetest.get_node
|
||||
|
||||
function creatura.get_node_def(node) -- Node can be name or pos
|
||||
if type(node) == "table" then
|
||||
node = get_node(node).name
|
||||
end
|
||||
local def = minetest.registered_nodes[node] or default_node_def
|
||||
if def.walkable
|
||||
and creatura.get_node_height_from_def(node) < 0.26 then
|
||||
def.walkable = false -- workaround for nodes like snow
|
||||
end
|
||||
return def
|
||||
end
|
||||
|
||||
local get_node_def = creatura.get_node_def
|
||||
|
||||
function creatura.get_ground_level(pos, range)
|
||||
range = range or 2
|
||||
local above = vector.round(pos)
|
||||
local under = {x = above.x, y = above.y - 1, z = above.z}
|
||||
if not get_node_def(above).walkable and get_node_def(under).walkable then return above end
|
||||
if get_node_def(above).walkable then
|
||||
for _ = 1, range do
|
||||
under = above
|
||||
above = {x = above.x, y = above.y + 1, z = above.z}
|
||||
if not get_node_def(above).walkable and get_node_def(under).walkable then return above end
|
||||
end
|
||||
end
|
||||
if not get_node_def(under).walkable then
|
||||
for _ = 1, range do
|
||||
above = under
|
||||
under = {x = under.x, y = under.y - 1, z = under.z}
|
||||
if not get_node_def(above).walkable and get_node_def(under).walkable then return above end
|
||||
end
|
||||
end
|
||||
return above
|
||||
end
|
||||
|
||||
function creatura.is_pos_moveable(pos, width, height)
|
||||
local edge1 = {
|
||||
x = pos.x - (width + 0.2),
|
||||
y = pos.y,
|
||||
z = pos.z - (width + 0.2),
|
||||
}
|
||||
local edge2 = {
|
||||
x = pos.x + (width + 0.2),
|
||||
y = pos.y,
|
||||
z = pos.z + (width + 0.2),
|
||||
}
|
||||
local base_p = {x = pos.x, y = pos.y, z = pos.z}
|
||||
local top_p = {x = pos.x, y = pos.y + height, z = pos.z}
|
||||
for z = edge1.z, edge2.z do
|
||||
for x = edge1.x, edge2.x do
|
||||
base_p.x, base_p.z = pos.x + x, pos.z + z
|
||||
top_p.x, top_p.z = pos.x + x, pos.z + z
|
||||
local ray = minetest.raycast(base_p, top_p, false, false)
|
||||
for pointed_thing in ray do
|
||||
if pointed_thing.type == "node" then
|
||||
local name = get_node(pointed_thing.under).name
|
||||
if creatura.get_node_def(name).walkable then
|
||||
return false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function is_blocked_thin(pos, height)
|
||||
local node
|
||||
local pos2 = {
|
||||
x = floor(pos.x + 0.5),
|
||||
y = floor(pos.y + 0.5) - 1,
|
||||
z = floor(pos.z + 0.5)
|
||||
}
|
||||
|
||||
for _ = 1, height do
|
||||
pos2.y = pos2.y + 1
|
||||
node = minetest.get_node_or_nil(pos2)
|
||||
|
||||
if not node
|
||||
or get_node_def(node.name).walkable then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function creatura.is_blocked(pos, width, height)
|
||||
if width <= 0.5 then
|
||||
return is_blocked_thin(pos, height)
|
||||
end
|
||||
|
||||
local p1 = {
|
||||
x = pos.x - (width + 0.2),
|
||||
y = pos.y,
|
||||
z = pos.z - (width + 0.2),
|
||||
}
|
||||
local p2 = {
|
||||
x = pos.x + (width + 0.2),
|
||||
y = pos.y + (height + 0.2),
|
||||
z = pos.z + (width + 0.2),
|
||||
}
|
||||
|
||||
local node
|
||||
local pos2 = {}
|
||||
for z = p1.z, p2.z do
|
||||
pos2.z = z
|
||||
for y = p1.y, p2.y do
|
||||
pos2.y = y
|
||||
for x = p1.x, p2.x do
|
||||
pos2.x = x
|
||||
node = minetest.get_node_or_nil(pos2)
|
||||
|
||||
if not node
|
||||
or get_node_def(node.name).walkable then
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function creatura.fast_ray_sight(pos1, pos2, water)
|
||||
local ray = minetest.raycast(pos1, pos2, false, water or false)
|
||||
local pointed_thing = ray:next()
|
||||
while pointed_thing do
|
||||
if pointed_thing.type == "node"
|
||||
and creatura.get_node_def(pointed_thing.under).walkable then
|
||||
return false, vec_dist(pos1, pointed_thing.intersection_point), pointed_thing.ref, pointed_thing.intersection_point
|
||||
end
|
||||
pointed_thing = ray:next()
|
||||
end
|
||||
return true, vec_dist(pos1, pos2), false, pos2
|
||||
end
|
||||
|
||||
local fast_ray_sight = creatura.fast_ray_sight
|
||||
|
||||
function creatura.sensor_floor(self, range, water)
|
||||
local pos = self.object:get_pos()
|
||||
local pos2 = vec_raise(pos, -range)
|
||||
local _, dist, node = fast_ray_sight(pos, pos2, water or false)
|
||||
return dist, node
|
||||
end
|
||||
|
||||
function creatura.sensor_ceil(self, range, water)
|
||||
local pos = vec_raise(self.object:get_pos(), self.height)
|
||||
local pos2 = vec_raise(pos, range)
|
||||
local _, dist, node = fast_ray_sight(pos, pos2, water or false)
|
||||
return dist, node
|
||||
end
|
||||
|
||||
function creatura.get_nearby_player(self, range)
|
||||
local pos = self.object:get_pos()
|
||||
if not pos then return end
|
||||
local stored = self._nearby_obj or {}
|
||||
local objects = (#stored > 0 and stored) or self:store_nearby_objects(range)
|
||||
for _, object in ipairs(objects) do
|
||||
if object:is_player()
|
||||
and creatura.is_alive(object) then
|
||||
return object
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function creatura.get_nearby_players(self, range)
|
||||
local pos = self.object:get_pos()
|
||||
if not pos then return end
|
||||
local stored = self._nearby_obj or {}
|
||||
local objects = (#stored > 0 and stored) or self:store_nearby_objects(range)
|
||||
local nearby = {}
|
||||
for _, object in ipairs(objects) do
|
||||
if object:is_player()
|
||||
and creatura.is_alive(object) then
|
||||
table.insert(nearby, object)
|
||||
end
|
||||
end
|
||||
return nearby
|
||||
end
|
||||
|
||||
function creatura.get_nearby_object(self, name, range)
|
||||
local pos = self.object:get_pos()
|
||||
if not pos then return end
|
||||
local stored = self._nearby_obj or {}
|
||||
local objects = (#stored > 0 and stored) or self:store_nearby_objects(range)
|
||||
for _, object in ipairs(objects) do
|
||||
local ent = creatura.is_alive(object) and object:get_luaentity()
|
||||
if ent
|
||||
and object ~= self.object
|
||||
and not ent._ignore
|
||||
and ((type(name) == "table" and contains_val(name, ent.name))
|
||||
or ent.name == name) then
|
||||
return object
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function creatura.get_nearby_objects(self, name, range)
|
||||
local pos = self.object:get_pos()
|
||||
if not pos then return end
|
||||
local stored = self._nearby_obj or {}
|
||||
local objects = (#stored > 0 and stored) or self:store_nearby_objects(range)
|
||||
local nearby = {}
|
||||
for _, object in ipairs(objects) do
|
||||
local ent = creatura.is_alive(object) and object:get_luaentity()
|
||||
if ent
|
||||
and object ~= self.object
|
||||
and not ent._ignore
|
||||
and ((type(name) == "table" and contains_val(name, ent.name))
|
||||
or ent.name == name) then
|
||||
table.insert(nearby, object)
|
||||
end
|
||||
end
|
||||
return nearby
|
||||
end
|
||||
|
||||
creatura.get_nearby_entity = creatura.get_nearby_object
|
||||
creatura.get_nearby_entities = creatura.get_nearby_objects
|
||||
|
||||
--------------------
|
||||
-- Global Mob API --
|
||||
--------------------
|
||||
|
||||
function creatura.default_water_physics(self)
|
||||
local pos = self.stand_pos
|
||||
local stand_node = self.stand_node
|
||||
if not pos or not stand_node then return end
|
||||
local gravity = self._movement_data.gravity or -9.8
|
||||
local submergence = self.liquid_submergence or 0.25
|
||||
local drag = self.liquid_drag or 0.7
|
||||
|
||||
if minetest.get_item_group(stand_node.name, "liquid") > 0 then -- In Liquid
|
||||
local vel = self.object:get_velocity()
|
||||
if not vel then return end
|
||||
|
||||
self.in_liquid = stand_node.name
|
||||
|
||||
if submergence < 1 then
|
||||
local mob_level = pos.y + (self.height * submergence)
|
||||
|
||||
-- Find Water Surface
|
||||
local nodes = minetest.find_nodes_in_area_under_air(
|
||||
{x = pos.x, y = pos.y, z = pos.z},
|
||||
{x = pos.x, y = pos.y + 3, z = pos.z},
|
||||
"group:liquid"
|
||||
) or {}
|
||||
|
||||
local surface_level = (#nodes > 0 and nodes[#nodes].y or pos.y + self.height + 3)
|
||||
surface_level = floor(surface_level + 0.9)
|
||||
|
||||
local height_diff = mob_level - surface_level
|
||||
|
||||
-- Apply Bouyancy
|
||||
if height_diff <= 0 then
|
||||
local displacement = clamp(abs(height_diff) / submergence, 0.5, 1) * self.width
|
||||
|
||||
self.object:set_acceleration({x = 0, y = displacement, z = 0})
|
||||
else
|
||||
self.object:set_acceleration({x = 0, y = gravity, z = 0})
|
||||
end
|
||||
end
|
||||
|
||||
-- Apply Drag
|
||||
self.object:set_velocity({
|
||||
x = vel.x * (1 - self.dtime * drag),
|
||||
y = vel.y * (1 - self.dtime * drag),
|
||||
z = vel.z * (1 - self.dtime * drag)
|
||||
})
|
||||
else
|
||||
self.in_liquid = nil
|
||||
|
||||
self.object:set_acceleration({x = 0, y = gravity, z = 0})
|
||||
end
|
||||
end
|
||||
|
||||
function creatura.default_vitals(self)
|
||||
local pos = self.object:get_pos()
|
||||
local node = minetest.get_node_or_nil(pos)
|
||||
if not pos or not node then return end
|
||||
|
||||
local max_fall = self.max_fall or 3
|
||||
local in_liquid = self.in_liquid
|
||||
local on_ground = self.touching_ground
|
||||
local damage = 0
|
||||
|
||||
-- Fall Damage
|
||||
if max_fall > 0
|
||||
and not in_liquid then
|
||||
local fall_start = self._fall_start or (not on_ground and pos.y)
|
||||
if fall_start
|
||||
and on_ground then
|
||||
damage = floor(fall_start - pos.y)
|
||||
if damage < max_fall then
|
||||
damage = 0
|
||||
else
|
||||
local resist = self.fall_resistance or 0
|
||||
damage = damage - damage * resist
|
||||
end
|
||||
fall_start = nil
|
||||
end
|
||||
self._fall_start = fall_start
|
||||
end
|
||||
|
||||
-- Environment Damage
|
||||
if self:timer(1) then
|
||||
local stand_def = creatura.get_node_def(node.name)
|
||||
local max_breath = self.max_breath or 0
|
||||
|
||||
-- Suffocation
|
||||
if max_breath > 0 then
|
||||
local head_pos = {x = pos.x, y = pos.y + self.height, z = pos.z}
|
||||
local head_def = creatura.get_node_def(head_pos)
|
||||
if head_def.groups
|
||||
and (minetest.get_item_group(head_def.name, "water") > 0
|
||||
or (head_def.walkable
|
||||
and head_def.groups.disable_suffocation ~= 1
|
||||
and head_def.drawtype == "normal")) then
|
||||
local breath = self._breath
|
||||
if breath <= 0 then
|
||||
damage = damage + 1
|
||||
else
|
||||
self._breath = breath - 1
|
||||
self:memorize("_breath", breath)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Burning
|
||||
local fire_resist = self.fire_resistance or 0
|
||||
if fire_resist < 1
|
||||
and minetest.get_item_group(stand_def.name, "igniter") > 0
|
||||
and stand_def.damage_per_second then
|
||||
local fire_damage = stand_def.damage_per_second - (stand_def.damage_per_second * fire_resist)
|
||||
damage = (damage or 0) + fire_damage
|
||||
end
|
||||
end
|
||||
|
||||
-- Apply Damage
|
||||
if damage > 0 then
|
||||
self:hurt(damage)
|
||||
self:indicate_damage()
|
||||
if random(4) < 2 then
|
||||
self:play_sound("hurt")
|
||||
end
|
||||
end
|
||||
|
||||
-- Entity Cramming
|
||||
if self:timer(5) then
|
||||
local objects = minetest.get_objects_inside_radius(pos, 0.2)
|
||||
if #objects > 10 then
|
||||
self:indicate_damage()
|
||||
self.hp = self:memorize("hp", -1)
|
||||
self:death_func()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function creatura.drop_items(self)
|
||||
if not self.drops then return end
|
||||
local pos = self.object:get_pos()
|
||||
if not pos then return end
|
||||
|
||||
local drop_def, item_name, min_items, max_items, chance, amount, drop_pos
|
||||
for i = 1, #self.drops do
|
||||
drop_def = self.drops[i]
|
||||
item_name = drop_def.name
|
||||
if not item_name then return end
|
||||
chance = drop_def.chance or 1
|
||||
|
||||
if random(chance) < 2 then
|
||||
min_items = drop_def.min or 1
|
||||
max_items = drop_def.max or 2
|
||||
amount = random(min_items, max_items)
|
||||
drop_pos = {
|
||||
x = pos.x + random(-5, 5) * 0.1,
|
||||
y = pos.y,
|
||||
z = pos.z + random(-5, 5) * 0.1
|
||||
}
|
||||
|
||||
local item = minetest.add_item(drop_pos, ItemStack(item_name .. " " .. amount))
|
||||
if item then
|
||||
item:add_velocity({
|
||||
x = random(-2, 2),
|
||||
y = 1.5,
|
||||
z = random(-2, 2)
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function creatura.basic_punch_func(self, puncher, tflp, tool_caps, dir)
|
||||
if not puncher then return end
|
||||
local tool
|
||||
local tool_name = ""
|
||||
local add_wear = false
|
||||
if puncher:is_player() then
|
||||
tool = puncher:get_wielded_item()
|
||||
tool_name = tool:get_name()
|
||||
add_wear = not minetest.is_creative_enabled(puncher:get_player_name())
|
||||
end
|
||||
if (self.immune_to
|
||||
and contains_val(self.immune_to, tool_name)) then
|
||||
return
|
||||
end
|
||||
local damage = 0
|
||||
local armor_grps = self.object:get_armor_groups() or self.armor_groups or {}
|
||||
for group, val in pairs(tool_caps.damage_groups or {}) do
|
||||
local dmg_x = tflp / (tool_caps.full_punch_interval or 1.4)
|
||||
damage = damage + val * clamp(dmg_x, 0, 1) * ((armor_grps[group] or 0) / 100.0)
|
||||
end
|
||||
if damage > 0 then
|
||||
local dist = vec_dist(self.object:get_pos(), puncher:get_pos())
|
||||
dir.y = 0.2
|
||||
if self.touching_ground then
|
||||
local power = clamp((damage / dist) * 8, 0, 8)
|
||||
self:apply_knockback(dir, power)
|
||||
end
|
||||
self:hurt(damage)
|
||||
end
|
||||
if add_wear then
|
||||
local wear = floor((tool_caps.full_punch_interval / 75) * 9000)
|
||||
tool:add_wear(wear)
|
||||
puncher:set_wielded_item(tool)
|
||||
end
|
||||
if (tflp or 0) > 0.5 then
|
||||
self:play_sound("hit")
|
||||
if random(2) < 2 then
|
||||
self:play_sound("hurt")
|
||||
end
|
||||
end
|
||||
self:indicate_damage()
|
||||
end
|
||||
|
||||
local path = minetest.get_modpath("creatura")
|
||||
|
||||
dofile(path.."/mob_meta.lua")
|
||||
Executable
+232
@@ -0,0 +1,232 @@
|
||||
-----------
|
||||
-- Boids --
|
||||
-----------
|
||||
|
||||
local abs = math.abs
|
||||
local atan2 = math.atan2
|
||||
local sin = math.sin
|
||||
local cos = math.cos
|
||||
|
||||
local function average_angle(tbl)
|
||||
local sum_sin, sum_cos = 0, 0
|
||||
for _, v in pairs(tbl) do
|
||||
sum_sin = sum_sin + sin(v)
|
||||
sum_cos = sum_cos + cos(v)
|
||||
end
|
||||
return atan2(sum_sin, sum_cos)
|
||||
end
|
||||
|
||||
local function average(tbl)
|
||||
local sum = 0
|
||||
for _,v in pairs(tbl) do -- Get the sum of all numbers in t
|
||||
sum = sum + v
|
||||
end
|
||||
return sum / #tbl
|
||||
end
|
||||
|
||||
local function interp_rad(a, b, w)
|
||||
local cs = (1 - w) * cos(a) + w * cos(b)
|
||||
local sn = (1 - w) * sin(a) + w * sin(b)
|
||||
return atan2(sn, cs)
|
||||
end
|
||||
|
||||
local vec_add = vector.add
|
||||
local vec_dir = vector.direction
|
||||
local vec_dist = vector.distance
|
||||
local vec_divide = vector.divide
|
||||
local vec_normal = vector.normalize
|
||||
|
||||
local function get_average_pos(vectors)
|
||||
local sum = {x = 0, y = 0, z = 0}
|
||||
for _, vec in pairs(vectors) do sum = vec_add(sum, vec) end
|
||||
return vec_divide(sum, #vectors)
|
||||
end
|
||||
|
||||
local function dist_2d(pos1, pos2)
|
||||
local a = vector.new(
|
||||
pos1.x,
|
||||
0,
|
||||
pos1.z
|
||||
)
|
||||
local b = vector.new(
|
||||
pos2.x,
|
||||
0,
|
||||
pos2.z
|
||||
)
|
||||
return vec_dist(a, b)
|
||||
end
|
||||
|
||||
local yaw2dir = minetest.yaw_to_dir
|
||||
local dir2yaw = minetest.dir_to_yaw
|
||||
|
||||
-- Get Boid Members --
|
||||
|
||||
function creatura.get_boid_cached(self)
|
||||
local pos = self.object:get_pos()
|
||||
if not pos then return end
|
||||
local radius = self.tracking_range * 0.5 or 4
|
||||
local members = self._movement_data.boids or {}
|
||||
local max_boids = self.max_boids or 7
|
||||
if #members > 0 then
|
||||
for i = #members, 1, -1 do
|
||||
local object = members[i]
|
||||
if not object or not object:get_yaw() then members[i] = nil end
|
||||
end
|
||||
if #members >= max_boids then return members end
|
||||
end
|
||||
local objects = minetest.get_objects_inside_radius(pos, radius)
|
||||
if #objects < 2 then return {} end
|
||||
for _, object in ipairs(objects) do
|
||||
local ent = object and object ~= self.object and object:get_luaentity()
|
||||
if ent
|
||||
and ent.name == self.name then
|
||||
local move_data = ent._movement_data
|
||||
if move_data
|
||||
and (not move_data.boids
|
||||
or #move_data.boids < max_boids) then
|
||||
table.insert(members, object)
|
||||
end
|
||||
end
|
||||
if #members >= max_boids then break end
|
||||
end
|
||||
self._movement_data.boids = members
|
||||
|
||||
return members
|
||||
end
|
||||
|
||||
-- Calculate Boid Movement Direction
|
||||
|
||||
function creatura.get_boid_dir(self)
|
||||
local pos = self.object:get_pos()
|
||||
if not pos then return end
|
||||
local boids = creatura.get_boid_cached(self)
|
||||
if #boids < 2 then return end
|
||||
local pos_no, pos_sum = 0, {x = 0, y = 0, z = 0}
|
||||
local sum_sin, sum_cos = 0, 0
|
||||
local lift_no, lift_sum = 0, 0
|
||||
|
||||
local vel
|
||||
local boid_pos
|
||||
local closest_pos
|
||||
for _, object in ipairs(boids) do
|
||||
if object then
|
||||
boid_pos, vel = object:get_pos(), object:get_velocity()
|
||||
if boid_pos then
|
||||
vel = vec_normal(vel)
|
||||
local obj_yaw = object:get_yaw()
|
||||
pos_no, pos_sum = pos_no + 1, vec_add(pos_sum, boid_pos)
|
||||
sum_sin, sum_cos = sum_sin + sin(obj_yaw), sum_cos + cos(obj_yaw)
|
||||
lift_no, lift_sum = lift_no + 1, lift_sum + vel.y
|
||||
if not closest_pos
|
||||
or vec_dist(pos, boid_pos) < vec_dist(pos, closest_pos) then
|
||||
closest_pos = boid_pos
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if not closest_pos then return end
|
||||
local center = vec_divide(pos_sum, pos_no)
|
||||
local lift = lift_sum / lift_no
|
||||
|
||||
local angle_sin, angle_cos
|
||||
local radius = self.tracking_range * 0.5 or 4
|
||||
local dist_factor = (radius - vec_dist(pos, closest_pos)) / radius
|
||||
|
||||
local alignment = atan2(sum_sin, sum_cos)
|
||||
local seperation = dir2yaw(vec_dir(closest_pos, pos))
|
||||
local cohesion = dir2yaw(vec_dir(pos, center))
|
||||
if dist_factor > 0.9 then
|
||||
seperation = interp_rad(alignment, seperation, 0.5)
|
||||
angle_sin, angle_cos = sin(seperation), cos(seperation)
|
||||
else
|
||||
angle_sin, angle_cos = sin(cohesion), cos(cohesion)
|
||||
end
|
||||
local angle = atan2(angle_sin + sin(alignment), angle_cos + cos(alignment))
|
||||
|
||||
local dir = yaw2dir(angle)
|
||||
dir.y = lift
|
||||
return vector.normalize(dir), boids
|
||||
end
|
||||
|
||||
-- Deprecated
|
||||
|
||||
function creatura.get_boid_members(pos, radius, name)
|
||||
local objects = minetest.get_objects_inside_radius(pos, radius)
|
||||
if #objects < 2 then return {} end
|
||||
local members = {}
|
||||
local max_boid = minetest.registered_entities[name].max_boids or 7
|
||||
for i = 1, #objects do
|
||||
if #members > max_boid then break end
|
||||
local object = objects[i]
|
||||
if object:get_luaentity()
|
||||
and object:get_luaentity().name == name then
|
||||
table.insert(members, object)
|
||||
end
|
||||
end
|
||||
if #members > 1 then
|
||||
for _, object in ipairs(members) do
|
||||
local ent = object and object:get_luaentity()
|
||||
if ent then
|
||||
ent._movement_data.boids = members
|
||||
end
|
||||
end
|
||||
end
|
||||
return members
|
||||
end
|
||||
|
||||
function creatura.get_boid_angle(self, _boids, range)
|
||||
local pos = self.object:get_pos()
|
||||
local boids = _boids or creatura.get_boid_members(pos, range or 4, self.name)
|
||||
if #boids < 3 then return end
|
||||
local yaw = self.object:get_yaw()
|
||||
local lift = self.object:get_velocity().y
|
||||
-- Add Boid data to tables
|
||||
local closest_pos
|
||||
local positions = {}
|
||||
local angles = {}
|
||||
local lifts = {}
|
||||
for i = 1, #boids do
|
||||
local boid = boids[i]
|
||||
if boid:get_pos() then
|
||||
local vel = boid:get_velocity()
|
||||
if boid ~= self.object
|
||||
and (abs(vel.x) > 0.1
|
||||
or abs(vel.z) > 0.1) then
|
||||
local boid_pos = boid:get_pos()
|
||||
table.insert(positions, boid_pos)
|
||||
table.insert(lifts, vec_normal(vel).y)
|
||||
table.insert(angles, boid:get_yaw())
|
||||
if not closest_pos
|
||||
or vec_dist(pos, boid_pos) < vec_dist(pos, closest_pos) then
|
||||
closest_pos = boid_pos
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if #positions < 3 then return end
|
||||
local center = get_average_pos(positions)
|
||||
local dir2closest = vec_dir(pos, closest_pos)
|
||||
-- Calculate Parameters
|
||||
local alignment = average_angle(angles)
|
||||
center = vec_add(center, yaw2dir(alignment))
|
||||
local dir2center = vec_dir(pos, center)
|
||||
local seperation = yaw + -(dir2yaw(dir2closest) - yaw)
|
||||
local cohesion = dir2yaw(dir2center)
|
||||
local params = {alignment}
|
||||
if dist_2d(pos, closest_pos) < (self.boid_seperation or self.width * 3) then
|
||||
table.insert(params, seperation)
|
||||
elseif dist_2d(pos, center) > (#boids * 0.33) * (self.boid_seperation or self.width * 3) then
|
||||
table.insert(params, cohesion)
|
||||
end
|
||||
-- Vertical Params
|
||||
local vert_alignment = average(lifts)
|
||||
local vert_seperation = (self.speed or 2) * -dir2closest.y
|
||||
local vert_cohesion = (self.speed or 2) * dir2center.y
|
||||
local vert_params = {vert_alignment}
|
||||
if math.abs(pos.y - closest_pos.y) < (self.boid_seperation or self.width * 3) then
|
||||
table.insert(vert_params, vert_seperation)
|
||||
elseif math.abs(pos.y - closest_pos.y) > 1.5 * (self.boid_seperation or self.width * 3) then
|
||||
table.insert(vert_params, vert_cohesion + (lift - vert_cohesion) * 0.1)
|
||||
end
|
||||
return average_angle(params), average(vert_params)
|
||||
end
|
||||
Executable
+280
@@ -0,0 +1,280 @@
|
||||
|
||||
Registration
|
||||
------------
|
||||
|
||||
creatura.register_mob(name, mob definition)
|
||||
|
||||
Mob Definition uses almost all entity definition params
|
||||
|
||||
{
|
||||
max_health = 10 -- Maximum Health
|
||||
damage = 0 -- Damage dealt by mob
|
||||
speed = 4 -- Maximum Speed
|
||||
tracking_range = 16 -- Maximum range for finding entities/blocks
|
||||
despawn_after = 1500 -- Despawn after being active for this amount of time
|
||||
|
||||
max_fall = 8 -- How far a mob can fall before taking damage (set to 0 to disable fall damage)
|
||||
turn_rate = 7 -- Turn Rate in rad/s
|
||||
bouyancy_multiplier = 1 -- Multiplier for bouyancy effects (set to 0 to disable bouyancy)
|
||||
hydrodynamics_multiplier = 1 -- Multiplier for hydroynamic effects (set to 0 to disable hydrodynamics)
|
||||
|
||||
hitbox = { -- Hitbox params (Uses custom registration to force get_pos() to always return bottom of box)
|
||||
width = 0.5, (total width = width * 2. A width of 0.5 results in a box with a total width of 1)
|
||||
height = 1 (total height of box)
|
||||
}
|
||||
|
||||
animations = {
|
||||
anim = {range = {x = 1, y = 10}, speed = 30, frame_blend = 0.3, loop = true}
|
||||
}
|
||||
|
||||
drops = {
|
||||
{name = (itemstring), min = 1, max = 3, chance = 1},
|
||||
}
|
||||
follow = {
|
||||
"farming:seed_wheat",
|
||||
"farming:seed_cotton"
|
||||
}
|
||||
|
||||
utility_stack = {
|
||||
-- Every second, all utilities in the stack are evaluated
|
||||
-- Whichever utilitiy's get_score function returns the highest number will be executed
|
||||
-- If multiple utilities have the same score, the one with the highest index is executed
|
||||
[1] = {
|
||||
`utility` -- name of utility to evaluate
|
||||
`get_score` -- function (only accepts `self` as an arg) that returns a number
|
||||
}
|
||||
}
|
||||
|
||||
activate_func = function(self, staticdata, dtime_s) -- called upon activation
|
||||
step_func = function(self, dtime, moveresult) -- called every server step
|
||||
death_func = function(self) -- called when mobs health drops to/below 0
|
||||
}
|
||||
|
||||
Lua Entity Methods
|
||||
------------------
|
||||
|
||||
`move(pos, method, speed, animation)`
|
||||
- `pos`: position to move to
|
||||
- `method`: method used to move to `pos`
|
||||
- `speed`: multiplier for `speed`
|
||||
- `animation`: animation to play while moving
|
||||
|
||||
`halt()`
|
||||
- stops movement
|
||||
|
||||
`turn_to(yaw[, turn_rate])`
|
||||
- `yaw`: yaw (in radians) to turn to
|
||||
- `turn_rate`: turn rate in rad/s (default: 10) -- likely to be deprecated
|
||||
|
||||
`set_gravity(gravity)`
|
||||
- `gravity`: vertical acceleration rate
|
||||
|
||||
`set_forward_velocity(speed)`
|
||||
- `speed`: rate in m/s to travel forward at
|
||||
|
||||
`set_vertical_velocity(speed)`
|
||||
- `speed`: rate in m/s to travel vertically at
|
||||
|
||||
`apply_knockback(dir, power)`
|
||||
- `dir`: direction vector
|
||||
- `power`: multiplier for dir
|
||||
|
||||
`punch_target(target)`
|
||||
- applies 'damage' to 'target'
|
||||
|
||||
`hurt(damage)`
|
||||
- `damage`: number to subtract from health (ignores armor)
|
||||
|
||||
`heal(health)`
|
||||
- `health`: number to add to health
|
||||
|
||||
`get_center_pos()`
|
||||
- returns position at center of hitbox
|
||||
|
||||
`pos_in_box(pos[, size])`
|
||||
- returns true if 'pos' is within hitbox
|
||||
- `size`: width of box to check in (optional)
|
||||
|
||||
`animate(anim)`
|
||||
- sets animation to `anim`
|
||||
|
||||
`set_texture(id, tbl)`
|
||||
- `id`: table index
|
||||
- `tbl`: table of textures
|
||||
|
||||
`set_scale(x)`
|
||||
- `x`: multiplier for base scale (0.5 sets scale to half, 2 sets scale to double)
|
||||
|
||||
`fix_attached_scale(parent)`
|
||||
- sets scale to appropriate value when attached to 'parent'
|
||||
- `parent`: object
|
||||
|
||||
`memorize(id, val)`
|
||||
-- stores `val` to staticdata
|
||||
- `id`: key for table
|
||||
- `val`: value to store
|
||||
|
||||
`forget(id)`
|
||||
-- removes `id` from staticdata
|
||||
|
||||
`recall(id)`
|
||||
-- returns value of `id` from staticdata
|
||||
|
||||
`timer(n)`
|
||||
-- returns true avery `n` seconds
|
||||
|
||||
`get_hitbox()`
|
||||
-- returns current hitbox
|
||||
|
||||
`get_height()`
|
||||
-- returns current height
|
||||
|
||||
`get_visual_size()`
|
||||
-- returns current visual size
|
||||
|
||||
`follow_wielded_item(player)`
|
||||
-- returns itemstack, item name of `player`s wielded item if item is in 'follow'
|
||||
|
||||
`get_target(target)`
|
||||
-- returns if `target` is alive, if mob has a line of sight with `target`, position of `target`
|
||||
|
||||
Utilities
|
||||
---------
|
||||
|
||||
* `creatura.is_valid(mob)`
|
||||
* Returns false if object doesn't exist, otherwise returns ObjectRef/PlayerRef
|
||||
* `mob`: Luaentity, ObjectRef, or PlayerRef
|
||||
|
||||
* `creatura.is_alive(mob)`
|
||||
* Returns false if object doesn't exist or is dead, otherwise returns ObjectRef/PlayerRef
|
||||
* `mob`: Luaentity, ObjectRef, or PlayerRef
|
||||
|
||||
Environment access
|
||||
------------------
|
||||
|
||||
* `creatura.get_node_height_from_def(name)`
|
||||
-- Returns total height of nodebox
|
||||
-- `name`: Itemstring/Name of node
|
||||
|
||||
|
||||
* `creatura.get_node_def(node)`
|
||||
-- Returns definition of node
|
||||
-- `node`: Itemstring/Name of node or position
|
||||
|
||||
* `creatura.get_ground_level(pos, max_diff)`
|
||||
* Returns first position above walkable node within `max_diff`
|
||||
|
||||
* `creatura.is_pos_moveable(pos, width, height)`
|
||||
* Returns true if a box with specified `width` and `height` can fit at `pos`
|
||||
* `width` should be the largest side of the collision box
|
||||
* Check from bottom of box
|
||||
|
||||
* `creatura.fast_ray_sight(pos1, pos2, water)`
|
||||
* Checks for line of sight between `pos1 and `pos2`
|
||||
* Returns bool
|
||||
* Returns distance to obstruction
|
||||
|
||||
* `creatura.sensor_floor(self, range, water)`
|
||||
* Finds distance to ground from bottom of entities hitbox
|
||||
* Returns distance to ground or `range` if no ground is found
|
||||
* `range`: Maximum range
|
||||
* `water`: If false, water will not be counted as ground
|
||||
|
||||
* `creatura.sensor_ceil(self, range, water)`
|
||||
* Finds distance to ceiling from top of entities hitbox
|
||||
* Returns distance to ceiling or `range` if no ceiling is found
|
||||
* `range`: Maximum range
|
||||
* `water`: If false, water will not be counted as ceiling
|
||||
|
||||
* `creatura.get_nearby_player(self)`
|
||||
* Finds player within `self.tracking_range`
|
||||
* Returns PlayerRef or nil
|
||||
|
||||
* `creatura.get_nearby_players(self)`
|
||||
* Finds players within `self.tracking_range`
|
||||
* Returns table of PlayerRefs or empty table
|
||||
|
||||
* `creatura.get_nearby_object(self, name)`
|
||||
* Finds object within `self.tracking_range`
|
||||
* Returns ObjectRef or nil
|
||||
* `name`: Name of object to search for
|
||||
|
||||
* `creatura.get_nearby_objects(self, name)`
|
||||
* Finds objects within `self.tracking_range`
|
||||
* Returns table of ObjectRefs or empty table
|
||||
* `name`: Name of object to search for
|
||||
|
||||
Global Mob API
|
||||
--------------
|
||||
|
||||
* `creatura.default_water_physics(self)`
|
||||
* Bouyancy and Drag physics used by default on all Mobs
|
||||
|
||||
* `creatura.default_vitals(self)`
|
||||
* Vitals used by default on all Mobs
|
||||
* Handles suffocation, drowning, fire damage, and fall damage
|
||||
|
||||
* `creatura.drop_items(self)`
|
||||
* Drops items from `self.drops`
|
||||
|
||||
* `creatura.basic_punch_func(self, puncher, time_from_last_punch, tool_capabilities, direction, damage)`
|
||||
* Deals damage
|
||||
* Applies knockback
|
||||
* Visualy and audibly indicates damage
|
||||
|
||||
Pathfinding
|
||||
-----------
|
||||
|
||||
Creatura's pathfinder uses the A* algorithm for speed, as well as Theta* for decent performance and more natural looking paths.
|
||||
|
||||
Both pathfinders will carry out pathfinding over multiple server steps to reduce lag spikes which does result in the path not
|
||||
being returned immediately, so your code will have to account for this.
|
||||
|
||||
The maximum amount of time the pathfinder can spend per-step (in microseconds) can be adjusted in settings.
|
||||
|
||||
|
||||
* `creatura.pathfinder.find_path(self, pos1, pos2, get_neighbors)`
|
||||
* Finds a path from `pos1` to `pos2`
|
||||
* `get_neighbors` is a function used to find valid neighbors
|
||||
* `creatura.pathfinder.get_neighbors_fly` and `creatura.pathfinder.get_neighbors_swim` are bundled by default
|
||||
|
||||
|
||||
* `creatura.pathfinder.find_path_theta(self, pos1, pos2, get_neighbors)`
|
||||
* Finds a path from `pos1` to `pos2`
|
||||
* Returns a path with arbitrary angles for natural looking paths at the expense of performance
|
||||
* `get_neighbors` is a function used to find valid neighbors
|
||||
* `creatura.pathfinder.get_neighbors_fly` and `creatura.pathfinder.get_neighbors_swim` are bundled by default
|
||||
|
||||
Spawning
|
||||
--------
|
||||
|
||||
NOTE: Globalstep spawning from early versions of the API likely won't recieve much/any support going forward. Use ABM Spawning instead.
|
||||
|
||||
* `creatura.register_abm_spawn(name, def)`
|
||||
* `name` of the mob to spawn
|
||||
* `def` is a table of spawn parameters
|
||||
* `chance` is the chance of a mob spawning every `interval`
|
||||
* (a `chance` of 30 and `interval` of 60 would mean a 1 in 30 chance of a mob spawning every 60 seconds)
|
||||
* `chance_on_load` same as `chance` but for LBM spawning (when a chunk is loaded for the first time)
|
||||
* `interval` is how often (in seconds) a spawn attempt will happen
|
||||
* `min_height` is the minimum height that a spawn attempt can happen at
|
||||
* a `min_height` of 0 would mean the mob cannot spawn below a y coordinate of 0
|
||||
* `max_height` is the maximum height that a spawn attempt can happen at
|
||||
* a `max_height` of 128 would mean the mob cannot spawn above a y coordinate of 128
|
||||
* `min_time` is the minimum time a mob can spawn at
|
||||
* `max_time` is the maximum time a mob can spawn at
|
||||
* set `min_time` to 19500 and `max_time` to 4500 to only spawn at night and swap the numbers to only spawn at day
|
||||
* `min_light` is the minimum light level a mob can spawn at
|
||||
* `max_light` is the maximum light level a mob can spawn at
|
||||
* `min_group` is the lowest number of mobs to spawn in a group at a time
|
||||
* value of 3 means the mob will always spawn with at least 3 mobs together
|
||||
* `max_group` is the highest number of mobs to spawn in a group at a time
|
||||
* `block_protected` will block spawning mobs in protected areas if set to true
|
||||
* `biomes` is a table of biomes the mob can spawn in
|
||||
* `nodes` is a table of nodes the mob can spawn in/on
|
||||
* `neighbors` is a table of nodes that must be adjacent to the spawn position
|
||||
* ex: set to `{"groups:tree"}` to force the mob to spawn next to a tree
|
||||
* `spawn_on_load` will spawn mobs when a chunk generates if set to true
|
||||
* `spawn_in_nodes` will spawn mobs inside the node rather than above if set to true
|
||||
* set this to true for mobs that spawn in water
|
||||
* `spawn_cap` is the maximum amount of the mob that can spawn within active block range
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
creatura = {}
|
||||
|
||||
local path = minetest.get_modpath("creatura")
|
||||
|
||||
dofile(path.."/api.lua")
|
||||
dofile(path.."/pathfinding.lua")
|
||||
dofile(path.."/pathfinder_deprecated.lua")
|
||||
dofile(path.."/methods.lua")
|
||||
|
||||
-- Optional Files --
|
||||
|
||||
-- Optional files can be safely removed
|
||||
-- by game developers who don't need the
|
||||
-- extra features
|
||||
|
||||
local function load_file(filepath, filename)
|
||||
if io.open(filepath .. "/" .. filename, "r") then
|
||||
dofile(filepath .. "/" .. filename)
|
||||
else
|
||||
minetest.log("action", "[Creatura] The file " .. filename .. " could not be loaded.")
|
||||
end
|
||||
end
|
||||
|
||||
load_file(path, "boids.lua")
|
||||
load_file(path, "spawning.lua")
|
||||
Executable
+677
@@ -0,0 +1,677 @@
|
||||
-------------
|
||||
-- Methods --
|
||||
-------------
|
||||
|
||||
local pi = math.pi
|
||||
local abs = math.abs
|
||||
local ceil = math.ceil
|
||||
local max = math.max
|
||||
local random = math.random
|
||||
local atan2 = math.atan2
|
||||
local sin = math.sin
|
||||
local cos = math.cos
|
||||
|
||||
local function diff(a, b) -- Get difference between 2 angles
|
||||
return atan2(sin(b - a), cos(b - a))
|
||||
end
|
||||
|
||||
local function clamp(val, _min, _max)
|
||||
if val < _min then
|
||||
val = _min
|
||||
elseif _max < val then
|
||||
val = _max
|
||||
end
|
||||
return val
|
||||
end
|
||||
|
||||
local vec_add = vector.add
|
||||
local vec_normal = vector.normalize
|
||||
local vec_len = vector.length
|
||||
local vec_dist = vector.distance
|
||||
local vec_dir = vector.direction
|
||||
local vec_dot = vector.dot
|
||||
local vec_multi = vector.multiply
|
||||
local vec_sub = vector.subtract
|
||||
local yaw2dir = minetest.yaw_to_dir
|
||||
local dir2yaw = minetest.dir_to_yaw
|
||||
|
||||
--[[local function debugpart(pos, time, tex)
|
||||
minetest.add_particle({
|
||||
pos = pos,
|
||||
texture = tex or "creatura_particle_red.png",
|
||||
expirationtime = time or 0.1,
|
||||
glow = 16,
|
||||
size = 24
|
||||
})
|
||||
end]]
|
||||
|
||||
---------------------
|
||||
-- Local Utilities --
|
||||
---------------------
|
||||
|
||||
local get_node_def = creatura.get_node_def
|
||||
--local get_node_height = creatura.get_node_height_from_def
|
||||
|
||||
function creatura.get_collision(self, dir, range)
|
||||
local pos, yaw = self.object:get_pos(), self.object:get_yaw()
|
||||
if not pos then return end
|
||||
local width, height = self.width or 0.5, self.height or 1
|
||||
|
||||
dir = dir or yaw2dir(yaw)
|
||||
|
||||
pos.x = pos.x + dir.x * width
|
||||
pos.z = pos.z + dir.z * width
|
||||
|
||||
local cos_yaw = cos(yaw)
|
||||
local sin_yaw = sin(yaw)
|
||||
|
||||
local width_i = width / ceil(width)
|
||||
local height_i = height / ceil(height)
|
||||
|
||||
local pos_x, pos_y, pos_z = pos.x, pos.y, pos.z
|
||||
local dir_x, dir_y, dir_z = dir.x, dir.y, dir.z
|
||||
|
||||
local pos2 = {x = pos_x, y = pos_y, z = pos_z}
|
||||
local collision
|
||||
|
||||
|
||||
pos.y = pos.y + height * 0.5
|
||||
range = range or 4
|
||||
local low_score
|
||||
for _ = 0, range do
|
||||
if collision then return collision end
|
||||
pos_x = pos_x + dir_x
|
||||
pos_y = pos_y + dir_y
|
||||
pos_z = pos_z + dir_z
|
||||
|
||||
pos2.y = pos_y
|
||||
for x = -width, width, width_i do
|
||||
pos2.x = cos_yaw * ((pos_x + x) - pos_x) + pos_x
|
||||
pos2.z = sin_yaw * ((pos_x + x) - pos_x) + pos_z
|
||||
|
||||
for y = height, 0, -height_i do
|
||||
if y < self.stepheight or 1.1 then break end
|
||||
pos2.y = pos_y + y
|
||||
|
||||
if get_node_def(pos2).walkable then
|
||||
local score = abs(pos.y - pos2.y) * vec_dot(dir, vec_dir(pos, pos2))
|
||||
if not low_score
|
||||
or score < low_score then
|
||||
low_score = score
|
||||
collision = pos2
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
creatura.get_collision_ranged = creatura.get_collision
|
||||
|
||||
local get_collision = creatura.get_collision
|
||||
|
||||
local function get_avoidance_dir(self)
|
||||
local pos = self.object:get_pos()
|
||||
if not pos then return end
|
||||
local _, col_pos = get_collision(self)
|
||||
if col_pos then
|
||||
local vel = self.object:get_velocity()
|
||||
vel.y = 0
|
||||
local vel_len = vec_len(vel) * (1 + (self.step_delay or 0))
|
||||
local ahead = vec_add(pos, vec_normal(vel))
|
||||
local avoidance_force = vec_sub(ahead, col_pos)
|
||||
avoidance_force.y = 0
|
||||
avoidance_force = vec_multi(vec_normal(avoidance_force), (vel_len > 1 and vel_len) or 1)
|
||||
return vec_dir(pos, vec_add(ahead, avoidance_force))
|
||||
end
|
||||
end
|
||||
|
||||
local function get_collision_single(pos, water)
|
||||
local pos2 = {x = pos.x, y = pos.y, z = pos.z}
|
||||
local n_def = get_node_def(pos2)
|
||||
if n_def.walkable
|
||||
or (water and (n_def.groups.liquid or 0) > 0) then
|
||||
pos2.y = pos.y + 1
|
||||
n_def = get_node_def(pos2)
|
||||
local col_max = n_def.walkable or (water and (n_def.groups.liquid or 0) > 0)
|
||||
pos2.y = pos.y - 1
|
||||
local col_min = col_max and (n_def.walkable or (water and (n_def.groups.liquid or 0) > 0))
|
||||
if col_min then
|
||||
return pos
|
||||
else
|
||||
pos2.y = pos.y + 1
|
||||
return pos2
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function creatura.get_avoidance_lift(self, pos2, range)
|
||||
range = ceil(max(range or 1, 0.5))
|
||||
local height_half = (self.height or 1) * 0.5
|
||||
local center_y = pos2.y + height_half
|
||||
local check_pos = {x = pos2.x, y = center_y, z = pos2.z}
|
||||
|
||||
-- Find ceiling and floor collisions
|
||||
local def
|
||||
local ceil_pos
|
||||
local floor_pos
|
||||
for i = 1, range, 0.5 do -- 0.5 increment increases accuracy
|
||||
if ceil_pos and floor_pos then break end
|
||||
check_pos.y = center_y + i
|
||||
def = creatura.get_node_def(check_pos)
|
||||
if not ceil_pos
|
||||
and (def.walkable
|
||||
or minetest.get_item_group(def.name, "liquid") > 0) then
|
||||
ceil_pos = check_pos
|
||||
end
|
||||
check_pos.y = center_y - i
|
||||
def = creatura.get_node_def(check_pos)
|
||||
if not floor_pos
|
||||
and (def.walkable
|
||||
or minetest.get_item_group(def.name, "liquid") > 0) then
|
||||
floor_pos = check_pos
|
||||
end
|
||||
end
|
||||
|
||||
-- Calculate direction to average point of collisions
|
||||
check_pos.y = center_y
|
||||
local offset = {x = 0, y = height_half + range, z = 0}
|
||||
if not ceil_pos then ceil_pos = vec_add(check_pos, offset) end
|
||||
if not floor_pos then floor_pos = vec_sub(check_pos, offset) end
|
||||
|
||||
local dist_up = ceil_pos.y - center_y
|
||||
local dist_down = floor_pos.y - center_y
|
||||
|
||||
local altitude = (dist_up + dist_down) / 2
|
||||
|
||||
return ((check_pos.y + altitude) - center_y) / range * 2
|
||||
end
|
||||
|
||||
function creatura.get_avoidance_lift_aquatic(self, pos2, range)
|
||||
range = ceil(max(range or 1, 0.5))
|
||||
local height_half = (self.height or 1) * 0.5
|
||||
local center_y = pos2.y + height_half
|
||||
local check_pos = {x = pos2.x, y = center_y, z = pos2.z}
|
||||
|
||||
-- Find ceiling and floor collisions
|
||||
local ceil_pos
|
||||
local floor_pos
|
||||
for i = 1, range, 0.5 do -- 0.5 increment increases accuracy
|
||||
if ceil_pos and floor_pos then break end
|
||||
check_pos.y = center_y + i
|
||||
if not ceil_pos
|
||||
and minetest.get_item_group(creatura.get_node_def(check_pos).name, "liquid") < 1 then
|
||||
ceil_pos = check_pos
|
||||
end
|
||||
check_pos.y = center_y - i
|
||||
if not floor_pos
|
||||
and minetest.get_item_group(creatura.get_node_def(check_pos).name, "liquid") < 1 then
|
||||
floor_pos = check_pos
|
||||
end
|
||||
end
|
||||
|
||||
-- Calculate direction to average point of collisions
|
||||
check_pos.y = center_y
|
||||
local offset = {x = 0, y = height_half + range, z = 0}
|
||||
if not ceil_pos then ceil_pos = vec_add(check_pos, offset) end
|
||||
if not floor_pos then floor_pos = vec_sub(check_pos, offset) end
|
||||
|
||||
local dist_up = ceil_pos.y - center_y
|
||||
local dist_down = floor_pos.y - center_y
|
||||
|
||||
local altitude = (dist_up + dist_down) / 2
|
||||
|
||||
return ((check_pos.y + altitude) - center_y) / range * 2
|
||||
end
|
||||
|
||||
----------------------------
|
||||
-- Context Based Steering --
|
||||
----------------------------
|
||||
|
||||
local steer_directions = {
|
||||
vec_normal({x = 1, y = 0, z = 0}),
|
||||
vec_normal({x = 1, y = 0, z = 1}),
|
||||
vec_normal({x = 0, y = 0, z = 1}),
|
||||
vec_normal({x = -1, y = 0, z = 0}),
|
||||
vec_normal({x = -1, y = 0, z = -1}),
|
||||
vec_normal({x = 0, y = 0, z = -1}),
|
||||
vec_normal({x = 1, y = 0, z = -1}),
|
||||
vec_normal({x = -1, y = 0, z = 1})
|
||||
}
|
||||
|
||||
-- Context Methods
|
||||
|
||||
function creatura.get_context_default(self, goal, steer_dir, interest, danger, range)
|
||||
local pos = self.object:get_pos()
|
||||
if not pos then return end
|
||||
local width, height = self.width or 0.5, self.height or 1
|
||||
local y_offset = math.min(self.stepheight or 1.1, height)
|
||||
pos.y = pos.y + y_offset
|
||||
local collision
|
||||
|
||||
local ray = minetest.raycast(pos, vec_add(pos, vec_multi(steer_dir, width + range)), false, false)
|
||||
local pointed = ray:next()
|
||||
if pointed
|
||||
and pointed.type == "node"
|
||||
and creatura.get_node_def(pointed.under).walkable then
|
||||
collision = pointed.under
|
||||
end
|
||||
|
||||
if collision then
|
||||
local dir2goal = vec_normal(vec_dir(pos, goal))
|
||||
local dir2col = vec_normal(vec_dir(pos, collision))
|
||||
local dist2col = vec_dist(pos, collision) - width
|
||||
local dot_score = vec_dot(dir2col, dir2goal)
|
||||
local dist_score = (range - dist2col) / range
|
||||
interest = interest - dot_score
|
||||
danger = dist_score
|
||||
end
|
||||
return interest, danger
|
||||
end
|
||||
|
||||
function creatura.get_context_large(self, goal, steer_dir, interest, danger, range)
|
||||
local pos = self.object:get_pos()
|
||||
if not pos then return end
|
||||
local width, height = self.width or 0.5, self.height or 1
|
||||
local y_offset = math.min(self.stepheight or height)
|
||||
pos.y = pos.y + y_offset
|
||||
local collision = creatura.get_collision(self, steer_dir, range)
|
||||
|
||||
if collision then
|
||||
local dir2goal = vec_normal(vec_dir(pos, goal))
|
||||
local dir2col = vec_normal(vec_dir(pos, collision))
|
||||
local dist2col = vec_dist(pos, collision) - width
|
||||
local dot_score = vec_dot(dir2col, dir2goal)
|
||||
local dist_score = (range - dist2col) / range
|
||||
interest = interest - dot_score
|
||||
danger = dist_score
|
||||
end
|
||||
return interest, danger
|
||||
end
|
||||
|
||||
function creatura.get_context_small(self, goal, steer_dir, interest, danger, range)
|
||||
local pos = self.object:get_pos()
|
||||
if not pos then return end
|
||||
pos = vector.round(pos)
|
||||
local width = self.width or 0.5
|
||||
local collision = get_collision_single(vec_add(pos, steer_dir))
|
||||
|
||||
if collision then
|
||||
local dir2goal = vec_normal(vec_dir(pos, goal))
|
||||
local dir2col = vec_normal(vec_dir(pos, collision))
|
||||
local dist2col = vec_dist(pos, collision) - width
|
||||
local dot_score = vec_dot(dir2col, dir2goal)
|
||||
local dist_score = (range - dist2col) / range
|
||||
interest = interest - dot_score
|
||||
danger = dist_score
|
||||
end
|
||||
return interest, danger
|
||||
end
|
||||
|
||||
function creatura.get_context_small_aquatic(self, goal, steer_dir, interest, danger, range)
|
||||
local pos = self.object:get_pos()
|
||||
if not pos then return end
|
||||
pos = vector.round(pos)
|
||||
local width = self.width or 0.5
|
||||
local pos2 = vec_add(pos, steer_dir)
|
||||
local collision = minetest.get_item_group(get_node_def(pos2).name, "liquid") < 1 and pos2
|
||||
|
||||
if collision then
|
||||
local dir2goal = vec_normal(vec_dir(pos, goal))
|
||||
local dir2col = vec_normal(vec_dir(pos, collision))
|
||||
local dist2col = vec_dist(pos, collision) - width
|
||||
local dot_score = vec_dot(dir2col, dir2goal)
|
||||
local dist_score = (range - dist2col) / range
|
||||
interest = interest - dot_score
|
||||
danger = dist_score
|
||||
end
|
||||
return interest, danger
|
||||
end
|
||||
|
||||
-- Calculate Steering
|
||||
|
||||
function creatura.calc_steering(self, goal, get_context, range)
|
||||
if not goal then return end
|
||||
get_context = get_context or creatura.get_context_default
|
||||
local pos, yaw = self.object:get_pos(), self.object:get_yaw()
|
||||
if not pos or not yaw then return end
|
||||
range = math.max(range or 2, 2)
|
||||
local dir2goal = vec_normal(vec_dir(pos, goal))
|
||||
local output_dir = {x = 0, y = dir2goal.y, z = 0}
|
||||
|
||||
-- Cached variables
|
||||
local dir
|
||||
for _, _dir in ipairs(steer_directions) do
|
||||
dir = {x = _dir.x, y = dir2goal.y, z = _dir.z}
|
||||
local score = vec_dot(dir2goal, dir)
|
||||
local interest = clamp(score, 0, 1)
|
||||
local danger = 0
|
||||
if interest > 0 then -- Direction is within 90 degrees of goal
|
||||
interest, danger = get_context(self, goal, dir, interest, danger, range)
|
||||
end
|
||||
score = interest - danger
|
||||
output_dir = vector.add(output_dir, vector.multiply(dir, score))
|
||||
end
|
||||
return vec_normal(output_dir)
|
||||
end
|
||||
|
||||
-- DEPRECATED
|
||||
|
||||
function creatura.get_context_steering(self, goal, range, water)
|
||||
local context = creatura.get_context_default
|
||||
local width, height = self.width, self.height
|
||||
if width > 0.5
|
||||
or height > 1 then
|
||||
context = creatura.get_context_large
|
||||
elseif water then
|
||||
context = creatura.get_context_small_aquatic
|
||||
end
|
||||
return creatura.calc_steering(self, goal, context, range)
|
||||
end
|
||||
|
||||
-------------
|
||||
-- Actions --
|
||||
-------------
|
||||
|
||||
-- Actions are more specific behaviors used
|
||||
-- to compose a Utility.
|
||||
|
||||
-- Move
|
||||
|
||||
function creatura.action_move(self, pos2, timeout, method, speed_factor, anim)
|
||||
local timer = timeout or 4
|
||||
local function func(_self)
|
||||
timer = timer - _self.dtime
|
||||
self:animate(anim or "walk")
|
||||
local safe = true
|
||||
if _self.max_fall
|
||||
and _self.max_fall > 0 then
|
||||
local pos = self.object:get_pos()
|
||||
if not pos then return end
|
||||
safe = _self:is_pos_safe(pos2)
|
||||
end
|
||||
if timer <= 0
|
||||
or not safe
|
||||
or _self:move_to(pos2, method or "creatura:obstacle_avoidance", speed_factor or 0.5) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
self:set_action(func)
|
||||
end
|
||||
|
||||
creatura.action_walk = creatura.action_move -- Support for outdated versions
|
||||
|
||||
-- Idle
|
||||
|
||||
function creatura.action_idle(self, time, anim)
|
||||
local timer = time or 1
|
||||
local function func(_self)
|
||||
_self:set_gravity(-9.8)
|
||||
_self:halt()
|
||||
_self:animate(anim or "stand")
|
||||
timer = timer - _self.dtime
|
||||
if timer <= 0 then
|
||||
return true
|
||||
end
|
||||
end
|
||||
self:set_action(func)
|
||||
end
|
||||
|
||||
-- Rotate on Z axis in random direction until 90 degree angle is reached
|
||||
|
||||
function creatura.action_fallover(self)
|
||||
local zrot = 0
|
||||
local init = false
|
||||
local dir = 1
|
||||
local rot = self.object:get_rotation()
|
||||
local function func(_self)
|
||||
if not init then
|
||||
_self:animate("stand")
|
||||
if random(2) < 2 then
|
||||
dir = -1
|
||||
end
|
||||
init = true
|
||||
end
|
||||
rot = _self.object:get_rotation()
|
||||
local goal = (pi * 0.5) * dir
|
||||
local step = _self.dtime
|
||||
if step > 0.5 then step = 0.5 end
|
||||
zrot = zrot + (pi * dir) * step
|
||||
_self.object:set_rotation({x = rot.x, y = rot.y, z = zrot})
|
||||
if (dir > 0 and zrot >= goal)
|
||||
or (dir < 0 and zrot <= goal) then return true end
|
||||
end
|
||||
self:set_action(func)
|
||||
end
|
||||
|
||||
----------------------
|
||||
-- Movement Methods --
|
||||
----------------------
|
||||
|
||||
-- Pathfinding
|
||||
|
||||
--[[local function trim_path(pos, path)
|
||||
if #path < 2 then return end
|
||||
local trim = false
|
||||
local closest
|
||||
for i = #path, 1, -1 do
|
||||
if not path[i] then break end
|
||||
if (closest
|
||||
and vec_dist(pos, path[i]) > vec_dist(pos, path[closest]))
|
||||
or trim then
|
||||
table.remove(path, i)
|
||||
trim = true
|
||||
else
|
||||
closest = i
|
||||
end
|
||||
end
|
||||
return path
|
||||
end]]
|
||||
|
||||
creatura.register_movement_method("creatura:pathfind_theta", function(self)
|
||||
local path = {}
|
||||
local steer_to
|
||||
local steer_int = 0
|
||||
local arrival_threshold = clamp(self.width, 0.5, 1)
|
||||
|
||||
self:set_gravity(-9.8)
|
||||
local function func(_self, goal, speed_factor)
|
||||
local pos = _self.object:get_pos()
|
||||
if not pos or not goal then return end
|
||||
|
||||
if vec_dist(pos, goal) < arrival_threshold then
|
||||
_self:halt()
|
||||
return true
|
||||
end
|
||||
|
||||
-- Calculate Movement
|
||||
local turn_rate = abs(_self.turn_rate or 5)
|
||||
local speed = abs(_self.speed or 2) * speed_factor or 0.5
|
||||
local path_dir = #path > 0 and vec_dir(pos, path[2] or path[1])
|
||||
|
||||
steer_int = (steer_int > 0 and steer_int - _self.dtime) or 1 / math.max(speed, 1)
|
||||
steer_to = path_dir or (steer_int <= 0 and creatura.calc_steering(_self, goal)) or steer_to
|
||||
|
||||
path = (#path > 0 and path) or (creatura.pathfinder.find_path_theta(_self, pos, goal) or {})
|
||||
|
||||
if path_dir
|
||||
and ((path[2] and vec_dist(pos, path[2]) < arrival_threshold)
|
||||
or vec_dist(pos, path[1]) < arrival_threshold) then
|
||||
table.remove(path, 1)
|
||||
end
|
||||
|
||||
-- Apply Movement
|
||||
_self:turn_to(dir2yaw(steer_to or vec_dir(pos, goal)), turn_rate)
|
||||
_self:set_forward_velocity(speed)
|
||||
end
|
||||
return func
|
||||
end)
|
||||
|
||||
creatura.register_movement_method("creatura:pathfind", function(self)
|
||||
local path = {}
|
||||
local steer_to
|
||||
local steer_int = 0
|
||||
local arrival_threshold = clamp(self.width, 0.5, 1)
|
||||
|
||||
self:set_gravity(-9.8)
|
||||
local function func(_self, goal, speed_factor)
|
||||
local pos = _self.object:get_pos()
|
||||
if not pos or not goal then return end
|
||||
|
||||
if vec_dist(pos, goal) < arrival_threshold then
|
||||
_self:halt()
|
||||
return true
|
||||
end
|
||||
|
||||
-- Calculate Movement
|
||||
local turn_rate = abs(_self.turn_rate or 5)
|
||||
local speed = abs(_self.speed or 2) * speed_factor or 0.5
|
||||
local path_dir = #path > 0 and vec_dir(pos, path[2] or path[1])
|
||||
|
||||
steer_int = (steer_int > 0 and steer_int - _self.dtime) or 1 / math.max(speed, 1)
|
||||
steer_to = path_dir or (steer_int <= 0 and creatura.calc_steering(_self, goal)) or steer_to
|
||||
|
||||
path = (#path > 0 and path) or (creatura.pathfinder.find_path(_self, pos, goal) or {})
|
||||
|
||||
if path_dir
|
||||
and ((path[2] and vec_dist(pos, path[2]) < arrival_threshold + 0.5)
|
||||
or vec_dist(pos, path[1]) < arrival_threshold) then
|
||||
table.remove(path, 1)
|
||||
end
|
||||
|
||||
-- Apply Movement
|
||||
_self:turn_to(dir2yaw(steer_to or vec_dir(pos, goal)), turn_rate)
|
||||
_self:set_forward_velocity(speed)
|
||||
end
|
||||
return func
|
||||
end)
|
||||
|
||||
|
||||
-- Steering
|
||||
|
||||
creatura.register_movement_method("creatura:steer_small", function(self)
|
||||
local steer_to
|
||||
local steer_int = 0
|
||||
self:set_gravity(-9.8)
|
||||
local function func(_self, goal, speed_factor)
|
||||
local pos = _self.object:get_pos()
|
||||
if not pos or not goal then return end
|
||||
if vec_dist(pos, goal) < clamp(self.width, 0.5, 1) then
|
||||
_self:halt()
|
||||
return true
|
||||
end
|
||||
-- Calculate Movement
|
||||
local turn_rate = abs(_self.turn_rate or 5)
|
||||
local speed = abs(_self.speed or 2) * speed_factor or 0.5
|
||||
steer_int = (steer_int > 0 and steer_int - _self.dtime) or 1 / math.max(speed, 1)
|
||||
steer_to = (steer_int <= 0 and creatura.calc_steering(_self, goal)) or steer_to
|
||||
-- Apply Movement
|
||||
_self:turn_to(dir2yaw(steer_to or vec_dir(pos, goal)), turn_rate)
|
||||
_self:set_forward_velocity(speed)
|
||||
end
|
||||
return func
|
||||
end)
|
||||
|
||||
creatura.register_movement_method("creatura:steer_large", function(self)
|
||||
local steer_to
|
||||
local steer_int = 0
|
||||
self:set_gravity(-9.8)
|
||||
local function func(_self, goal, speed_factor)
|
||||
local pos = _self.object:get_pos()
|
||||
if not pos or not goal then return end
|
||||
if vec_dist(pos, goal) < clamp(self.width, 0.5, 1) then
|
||||
_self:halt()
|
||||
return true
|
||||
end
|
||||
-- Calculate Movement
|
||||
local turn_rate = abs(_self.turn_rate or 5)
|
||||
local speed = abs(_self.speed or 2) * speed_factor or 0.5
|
||||
steer_int = (steer_int > 0 and steer_int - _self.dtime) or 1 / math.max(speed, 1)
|
||||
steer_to = (steer_int <= 0 and creatura.calc_steering(_self, goal, creatura.get_context_large)) or steer_to
|
||||
-- Apply Movement
|
||||
_self:turn_to(dir2yaw(steer_to or vec_dir(pos, goal)), turn_rate)
|
||||
_self:set_forward_velocity(speed)
|
||||
end
|
||||
return func
|
||||
end)
|
||||
|
||||
creatura.register_movement_method("creatura:walk_simple", function(self)
|
||||
self:set_gravity(-9.8)
|
||||
local function func(_self, goal, speed_factor)
|
||||
local pos = _self.object:get_pos()
|
||||
if not pos or not goal then return end
|
||||
if vec_dist(pos, goal) < clamp(self.width, 0.5, 1) then
|
||||
_self:halt()
|
||||
return true
|
||||
end
|
||||
-- Calculate Movement
|
||||
local turn_rate = abs(_self.turn_rate or 5)
|
||||
local speed = abs(_self.speed or 2) * speed_factor or 0.5
|
||||
-- Apply Movement
|
||||
_self:turn_to(dir2yaw(vec_dir(pos, goal)), turn_rate)
|
||||
_self:set_forward_velocity(speed)
|
||||
end
|
||||
return func
|
||||
end)
|
||||
|
||||
-- Deprecated
|
||||
|
||||
creatura.register_movement_method("creatura:context_based_steering", function(self)
|
||||
local steer_to
|
||||
local steer_int = 0
|
||||
self:set_gravity(-9.8)
|
||||
local function func(_self, goal, speed_factor)
|
||||
local pos = _self.object:get_pos()
|
||||
if not pos or not goal then return end
|
||||
if vec_dist(pos, goal) < clamp(self.width, 0.5, 1) then
|
||||
_self:halt()
|
||||
return true
|
||||
end
|
||||
-- Calculate Movement
|
||||
local turn_rate = abs(_self.turn_rate or 5)
|
||||
local speed = abs(_self.speed or 2) * speed_factor or 0.5
|
||||
steer_int = (steer_int > 0 and steer_int - _self.dtime) or 1 / math.max(speed, 1)
|
||||
steer_to = (steer_int <= 0 and creatura.calc_steering(_self, goal, creatura.get_context_large)) or steer_to
|
||||
-- Apply Movement
|
||||
_self:turn_to(dir2yaw(steer_to or vec_dir(pos, goal)), turn_rate)
|
||||
_self:set_forward_velocity(speed)
|
||||
end
|
||||
return func
|
||||
end)
|
||||
|
||||
creatura.register_movement_method("creatura:obstacle_avoidance", function(self)
|
||||
local box = clamp(self.width, 0.5, 1.5)
|
||||
local steer_to
|
||||
local steer_timer = 0.25
|
||||
local function func(_self, goal, speed_factor)
|
||||
local pos = _self.object:get_pos()
|
||||
if not pos then return end
|
||||
self:set_gravity(-9.8)
|
||||
-- Return true when goal is reached
|
||||
if vec_dist(pos, goal) < box * 1.33 then
|
||||
_self:halt()
|
||||
return true
|
||||
end
|
||||
steer_timer = (steer_timer > 0 and steer_timer - _self.dtime) or 0.25
|
||||
-- Get movement direction
|
||||
steer_to = (steer_timer > 0 and steer_to) or (steer_timer <= 0 and get_avoidance_dir(_self))
|
||||
local goal_dir = steer_to or vec_dir(pos, goal)
|
||||
pos.y = pos.y + goal_dir.y
|
||||
local yaw = _self.object:get_yaw()
|
||||
local goal_yaw = dir2yaw(goal_dir)
|
||||
local speed = abs(_self.speed or 2) * speed_factor or 0.5
|
||||
local turn_rate = abs(_self.turn_rate or 5)
|
||||
-- Movement
|
||||
local yaw_diff = abs(diff(yaw, goal_yaw))
|
||||
if yaw_diff < pi * 0.25
|
||||
or steer_to then
|
||||
_self:set_forward_velocity(speed)
|
||||
else
|
||||
_self:set_forward_velocity(speed * 0.33)
|
||||
end
|
||||
_self:turn_to(goal_yaw, turn_rate)
|
||||
end
|
||||
return func
|
||||
end)
|
||||
|
||||
|
||||
Executable
+1248
File diff suppressed because it is too large
Load Diff
Executable
+2
@@ -0,0 +1,2 @@
|
||||
name = creatura
|
||||
description = A performant, semi-modular mob API
|
||||
Executable
+805
@@ -0,0 +1,805 @@
|
||||
-----------------
|
||||
-- Pathfinding --
|
||||
-----------------
|
||||
|
||||
local a_star_alloted_time = tonumber(minetest.settings:get("creatura_a_star_alloted_time")) or 500
|
||||
local theta_star_alloted_time = tonumber(minetest.settings:get("creatura_theta_star_alloted_time")) or 700
|
||||
|
||||
local floor = math.floor
|
||||
local abs = math.abs
|
||||
|
||||
local vec_dist, vec_round = vector.distance, vector.round
|
||||
|
||||
local moveable = creatura.is_pos_moveable
|
||||
|
||||
local function get_distance(start_pos, end_pos)
|
||||
local distX = abs(start_pos.x - end_pos.x)
|
||||
local distZ = abs(start_pos.z - end_pos.z)
|
||||
|
||||
if distX > distZ then
|
||||
return 14 * distZ + 10 * (distX - distZ)
|
||||
else
|
||||
return 14 * distX + 10 * (distZ - distX)
|
||||
end
|
||||
end
|
||||
|
||||
local function get_distance_to_neighbor(start_pos, end_pos)
|
||||
local distX = abs(start_pos.x - end_pos.x)
|
||||
local distY = abs(start_pos.y - end_pos.y)
|
||||
local distZ = abs(start_pos.z - end_pos.z)
|
||||
|
||||
if distX > distZ then
|
||||
return (14 * distZ + 10 * (distX - distZ)) * (distY + 1)
|
||||
else
|
||||
return (14 * distX + 10 * (distZ - distX)) * (distY + 1)
|
||||
end
|
||||
end
|
||||
|
||||
local function is_on_ground(pos)
|
||||
local ground = {
|
||||
x = pos.x,
|
||||
y = pos.y - 1,
|
||||
z = pos.z
|
||||
}
|
||||
if creatura.get_node_def(ground).walkable then
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function vec_raise(v, n)
|
||||
return {x = v.x, y = v.y + n, z = v.z}
|
||||
end
|
||||
|
||||
local function get_line_of_sight(a, b)
|
||||
local steps = floor(vec_dist(a, b))
|
||||
local line = {}
|
||||
|
||||
for i = 0, steps do
|
||||
local pos
|
||||
|
||||
if steps > 0 then
|
||||
pos = {
|
||||
x = a.x + (b.x - a.x) * (i / steps),
|
||||
y = a.y + (b.y - a.y) * (i / steps),
|
||||
z = a.z + (b.z - a.z) * (i / steps)
|
||||
}
|
||||
else
|
||||
pos = a
|
||||
end
|
||||
table.insert(line, pos)
|
||||
end
|
||||
|
||||
if #line < 1 then
|
||||
return false
|
||||
else
|
||||
for i = 1, #line do
|
||||
local node = minetest.get_node(line[i])
|
||||
if creatura.get_node_def(node.name).walkable then
|
||||
return false
|
||||
end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- Find a path from start to goal
|
||||
|
||||
--[[local function debugpart(pos, time, tex)
|
||||
minetest.add_particle({
|
||||
pos = pos,
|
||||
texture = tex or "creatura_particle_red.png",
|
||||
expirationtime = time or 0.1,
|
||||
glow = 6,
|
||||
size = 12
|
||||
})
|
||||
end]]
|
||||
|
||||
local c_air = minetest.get_content_id("air")
|
||||
|
||||
local function is_pos_moveable_vm(pos, width, height, area, data)
|
||||
pos = vector.round(pos)
|
||||
local pos1 = {
|
||||
x = pos.x - math.ceil(width),
|
||||
y = pos.y,
|
||||
z = pos.z - math.ceil(width)
|
||||
}
|
||||
local pos2 = {
|
||||
x = pos.x + math.ceil(width),
|
||||
y = pos.y + math.ceil(height),
|
||||
z = pos.z + math.ceil(width)
|
||||
}
|
||||
for z = pos1.z, pos2.z do
|
||||
for y = pos1.y, pos2.y do
|
||||
for x = pos1.x, pos2.x do
|
||||
if not area:contains(x, y, z) then return false end
|
||||
local vi = area:index(x, y, z)
|
||||
local c = data[vi]
|
||||
if c ~= c_air then
|
||||
local c_name = minetest.get_name_from_content_id(c)
|
||||
if creatura.get_node_def(c_name).walkable then
|
||||
return false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local vm_buffer = {}
|
||||
|
||||
function creatura.find_lvm_path(self, start, goal, obj_width, obj_height, max_open, climb, fly, swim)
|
||||
climb = climb or false
|
||||
fly = fly or false
|
||||
swim = swim or false
|
||||
|
||||
if vec_dist(start, goal) > (self.tracking_range or 128) then return {} end
|
||||
|
||||
self._path_data.start = start
|
||||
|
||||
local path_neighbors = {
|
||||
{x = 1, y = 0, z = 0},
|
||||
{x = 1, y = 0, z = 1},
|
||||
{x = 0, y = 0, z = 1},
|
||||
{x = -1, y = 0, z = 1},
|
||||
{x = -1, y = 0, z = 0},
|
||||
{x = -1, y = 0, z = -1},
|
||||
{x = 0, y = 0, z = -1},
|
||||
{x = 1, y = 0, z = -1}
|
||||
}
|
||||
|
||||
if climb then
|
||||
table.insert(path_neighbors, {x = 0, y = 1, z = 0})
|
||||
end
|
||||
|
||||
if fly
|
||||
or swim then
|
||||
path_neighbors = {
|
||||
-- Central
|
||||
{x = 1, y = 0, z = 0},
|
||||
{x = 0, y = 0, z = 1},
|
||||
{x = -1, y = 0, z = 0},
|
||||
{x = 0, y = 0, z = -1},
|
||||
-- Directly Up or Down
|
||||
{x = 0, y = 1, z = 0},
|
||||
{x = 0, y = -1, z = 0}
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
local function get_neighbors(pos, width, height, tbl, open, closed, vm_area, vm_data)
|
||||
local result = {}
|
||||
for i = 1, #tbl do
|
||||
local neighbor = vector.add(pos, tbl[i])
|
||||
if not vm_area or not vm_data or not vm_area:containsp(neighbor) then return end
|
||||
local can_move = (not swim and get_line_of_sight({x = pos.x, y = neighbor.y, z = pos.z}, neighbor)) or true
|
||||
if open[minetest.hash_node_position(neighbor)]
|
||||
or closed[minetest.hash_node_position(neighbor)] then
|
||||
can_move = false
|
||||
end
|
||||
if can_move then
|
||||
can_move = is_pos_moveable_vm(neighbor, width, height, vm_area, vm_data)
|
||||
if not fly and not swim then
|
||||
if not can_move then -- Step Up
|
||||
local step = vec_raise(neighbor, 1)
|
||||
can_move = is_pos_moveable_vm(vec_round(step), width, height, vm_area, vm_data)
|
||||
neighbor = vec_round(step)
|
||||
else
|
||||
local step = creatura.get_ground_level(vector.new(neighbor), 1)
|
||||
if step.y < neighbor.y
|
||||
and is_pos_moveable_vm(vec_round(step), width, height, vm_area, vm_data) then
|
||||
neighbor = step
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if vector.equals(neighbor, goal) then
|
||||
can_move = true
|
||||
end
|
||||
if can_move
|
||||
and (not swim
|
||||
or creatura.get_node_def(neighbor).drawtype == "liquid") then
|
||||
table.insert(result, neighbor)
|
||||
end
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
local function find_path(_start, _goal)
|
||||
local us_time = minetest.get_us_time()
|
||||
|
||||
_start = {
|
||||
x = floor(_start.x + 0.5),
|
||||
y = floor(_start.y + 0.5),
|
||||
z = floor(_start.z + 0.5)
|
||||
}
|
||||
|
||||
_goal = {
|
||||
x = floor(_goal.x + 0.5),
|
||||
y = floor(_goal.y + 0.5),
|
||||
z = floor(_goal.z + 0.5)
|
||||
}
|
||||
|
||||
if _goal.x == _start.x
|
||||
and _goal.z == _start.z then -- No path can be found
|
||||
return nil
|
||||
end
|
||||
|
||||
local vm_area = self._path_data.vm_area
|
||||
local vm_data = self._path_data.vm_data
|
||||
|
||||
if not vm_area
|
||||
or not vm_data then
|
||||
local vm_center = vector.add(_start, vector.divide(vector.subtract(_goal, _start), 2))
|
||||
local vm_size = vec_dist(_goal, _start)
|
||||
if vm_size < 24 then vm_size = 24 end
|
||||
local e1 = vector.subtract(vm_center, vm_size)
|
||||
local e2 = vector.add(vm_center, vm_size)
|
||||
local vm = minetest.get_voxel_manip(e1, e2)
|
||||
e1, e2 = vm:read_from_map(e1, e2)
|
||||
vm_area = VoxelArea:new{MinEdge=e1, MaxEdge=e2}
|
||||
vm_data = vm:get_data(vm_buffer)
|
||||
end
|
||||
|
||||
local openSet = self._path_data.open or {}
|
||||
|
||||
local closedSet = self._path_data.closed or {}
|
||||
|
||||
local start_index = minetest.hash_node_position(_start)
|
||||
|
||||
openSet[start_index] = {
|
||||
pos = _start,
|
||||
parent = nil,
|
||||
gScore = 0,
|
||||
fScore = get_distance(_start, _goal)
|
||||
}
|
||||
|
||||
local count = self._path_data.count or 1
|
||||
|
||||
while count > 0 do
|
||||
-- Initialize ID and data
|
||||
local current_id, current = next(openSet)
|
||||
|
||||
-- Find lowest f cost
|
||||
for i, v in pairs(openSet) do
|
||||
if v.fScore < current.fScore then
|
||||
current_id = i
|
||||
current = v
|
||||
end
|
||||
end
|
||||
|
||||
-- Add lowest fScore to closedSet and remove from openSet
|
||||
openSet[current_id] = nil
|
||||
closedSet[current_id] = current
|
||||
|
||||
self._path_data.open = openSet
|
||||
self._path_data.closedSet = closedSet
|
||||
|
||||
local current_start = vec_round(self._path_data.start)
|
||||
|
||||
if closedSet[minetest.hash_node_position(current_start)] then
|
||||
start_index = minetest.hash_node_position(current_start)
|
||||
end
|
||||
|
||||
-- Reconstruct path if end is reached
|
||||
if ((is_on_ground(_goal)
|
||||
or fly)
|
||||
and current_id == minetest.hash_node_position(_goal))
|
||||
or (not fly
|
||||
and not is_on_ground(_goal)
|
||||
and math.abs(_goal.x - current.pos.x) < 1.1
|
||||
and math.abs(_goal.z - current.pos.z) < 1.1) then
|
||||
local path = {}
|
||||
local fail_safe = 0
|
||||
for _ in pairs(closedSet) do
|
||||
fail_safe = fail_safe + 1
|
||||
end
|
||||
repeat
|
||||
if not closedSet[current_id] then return end
|
||||
table.insert(path, closedSet[current_id].pos)
|
||||
current_id = closedSet[current_id].parent
|
||||
until current_id == start_index or #path >= fail_safe
|
||||
if not closedSet[current_id] then self._path_data = {} return nil end
|
||||
table.insert(path, closedSet[current_id].pos)
|
||||
local reverse_path = {}
|
||||
repeat table.insert(reverse_path, table.remove(path)) until #path == 0
|
||||
self._path_data = {}
|
||||
return reverse_path
|
||||
end
|
||||
|
||||
count = count - 1
|
||||
|
||||
local adjacent = get_neighbors(
|
||||
current.pos,
|
||||
obj_width,
|
||||
obj_height,
|
||||
path_neighbors,
|
||||
openSet,
|
||||
closedSet,
|
||||
vm_area,
|
||||
vm_data
|
||||
)
|
||||
|
||||
-- Go through neighboring nodes
|
||||
if not adjacent or #adjacent < 1 then self._path_data = {} return {} end
|
||||
for i = 1, #adjacent do
|
||||
local neighbor = {
|
||||
pos = adjacent[i],
|
||||
parent = current_id,
|
||||
gScore = 0,
|
||||
fScore = 0
|
||||
}
|
||||
local temp_gScore = current.gScore + get_distance_to_neighbor(current.pos, neighbor.pos)
|
||||
local new_gScore = 0
|
||||
if openSet[minetest.hash_node_position(neighbor.pos)] then
|
||||
new_gScore = openSet[minetest.hash_node_position(neighbor.pos)].gScore
|
||||
end
|
||||
if (temp_gScore < new_gScore
|
||||
or not openSet[minetest.hash_node_position(neighbor.pos)])
|
||||
and not closedSet[minetest.hash_node_position(neighbor.pos)] then
|
||||
if not openSet[minetest.hash_node_position(neighbor.pos)] then
|
||||
count = count + 1
|
||||
end
|
||||
local hCost = get_distance_to_neighbor(neighbor.pos, _goal)
|
||||
neighbor.gScore = temp_gScore
|
||||
neighbor.fScore = temp_gScore + hCost
|
||||
openSet[minetest.hash_node_position(neighbor.pos)] = neighbor
|
||||
end
|
||||
end
|
||||
if minetest.get_us_time() - us_time > a_star_alloted_time then
|
||||
self._path_data = {
|
||||
start = _start,
|
||||
open = openSet,
|
||||
closed = closedSet,
|
||||
count = count,
|
||||
vm_area = vm_area,
|
||||
vm_data = vm_data
|
||||
}
|
||||
return {}
|
||||
end
|
||||
if count > (max_open or 100) then
|
||||
self._path_data = {}
|
||||
return
|
||||
end
|
||||
end
|
||||
self._path_data = {}
|
||||
return nil
|
||||
end
|
||||
return find_path(start, goal)
|
||||
end
|
||||
|
||||
function creatura.find_path(self, start, goal, obj_width, obj_height, max_open, climb, fly, swim)
|
||||
climb = climb or false
|
||||
fly = fly or false
|
||||
swim = swim or false
|
||||
|
||||
start = self._path_data.start or start
|
||||
|
||||
self._path_data.start = start
|
||||
|
||||
local path_neighbors = {
|
||||
{x = 1, y = 0, z = 0},
|
||||
{x = 1, y = 0, z = 1},
|
||||
{x = 0, y = 0, z = 1},
|
||||
{x = -1, y = 0, z = 1},
|
||||
{x = -1, y = 0, z = 0},
|
||||
{x = -1, y = 0, z = -1},
|
||||
{x = 0, y = 0, z = -1},
|
||||
{x = 1, y = 0, z = -1}
|
||||
}
|
||||
|
||||
if climb then
|
||||
table.insert(path_neighbors, {x = 0, y = 1, z = 0})
|
||||
end
|
||||
|
||||
if fly
|
||||
or swim then
|
||||
path_neighbors = {
|
||||
-- Central
|
||||
{x = 1, y = 0, z = 0},
|
||||
{x = 0, y = 0, z = 1},
|
||||
{x = -1, y = 0, z = 0},
|
||||
{x = 0, y = 0, z = -1},
|
||||
-- Directly Up or Down
|
||||
{x = 0, y = 1, z = 0},
|
||||
{x = 0, y = -1, z = 0}
|
||||
}
|
||||
end
|
||||
|
||||
local function get_neighbors(pos, width, height, tbl, open, closed)
|
||||
local result = {}
|
||||
for i = 1, #tbl do
|
||||
local neighbor = vector.add(pos, tbl[i])
|
||||
local can_move = (not swim and get_line_of_sight({x = pos.x, y = neighbor.y, z = pos.z}, neighbor)) or true
|
||||
if open[minetest.hash_node_position(neighbor)]
|
||||
or closed[minetest.hash_node_position(neighbor)] then
|
||||
can_move = false
|
||||
end
|
||||
if can_move then
|
||||
can_move = moveable(neighbor, width, height)
|
||||
if not fly and not swim then
|
||||
if not can_move then -- Step Up
|
||||
local step = vec_raise(neighbor, 1)
|
||||
can_move = moveable(vec_round(step), width, height)
|
||||
neighbor = vec_round(step)
|
||||
else
|
||||
local step = creatura.get_ground_level(vector.new(neighbor), 1)
|
||||
if step.y < neighbor.y
|
||||
and moveable(vec_round(step), width, height) then
|
||||
neighbor = step
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if vector.equals(neighbor, goal) then
|
||||
can_move = true
|
||||
end
|
||||
if can_move
|
||||
and (not swim
|
||||
or creatura.get_node_def(neighbor).drawtype == "liquid") then
|
||||
table.insert(result, neighbor)
|
||||
end
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
local function find_path(_start, _goal)
|
||||
local us_time = minetest.get_us_time()
|
||||
|
||||
_start = {
|
||||
x = floor(_start.x + 0.5),
|
||||
y = floor(_start.y + 0.5),
|
||||
z = floor(_start.z + 0.5)
|
||||
}
|
||||
|
||||
_goal = {
|
||||
x = floor(_goal.x + 0.5),
|
||||
y = floor(_goal.y + 0.5),
|
||||
z = floor(_goal.z + 0.5)
|
||||
}
|
||||
|
||||
if _goal.x == _start.x
|
||||
and _goal.z == _start.z then -- No path can be found
|
||||
return nil
|
||||
end
|
||||
|
||||
local openSet = self._path_data.open or {}
|
||||
|
||||
local closedSet = self._path_data.closed or {}
|
||||
|
||||
local start_index = minetest.hash_node_position(_start)
|
||||
|
||||
openSet[start_index] = {
|
||||
pos = _start,
|
||||
parent = nil,
|
||||
gScore = 0,
|
||||
fScore = get_distance(_start, _goal)
|
||||
}
|
||||
|
||||
local count = self._path_data.count or 1
|
||||
|
||||
while count > 0 do
|
||||
if minetest.get_us_time() - us_time > a_star_alloted_time then
|
||||
self._path_data = {
|
||||
start = _start,
|
||||
open = openSet,
|
||||
closed = closedSet,
|
||||
count = count
|
||||
}
|
||||
return
|
||||
end
|
||||
-- Initialize ID and data
|
||||
local current_id, current = next(openSet)
|
||||
|
||||
-- Find lowest f cost
|
||||
for i, v in pairs(openSet) do
|
||||
if v.fScore < current.fScore then
|
||||
current_id = i
|
||||
current = v
|
||||
end
|
||||
end
|
||||
|
||||
-- Add lowest fScore to closedSet and remove from openSet
|
||||
openSet[current_id] = nil
|
||||
closedSet[current_id] = current
|
||||
|
||||
self._path_data.open = openSet
|
||||
self._path_data.closedSet = closedSet
|
||||
|
||||
-- Reconstruct path if end is reached
|
||||
if ((is_on_ground(_goal)
|
||||
or fly)
|
||||
and current_id == minetest.hash_node_position(_goal))
|
||||
or (not fly
|
||||
and not is_on_ground(_goal)
|
||||
and _goal.x == current.pos.x
|
||||
and _goal.z == current.pos.z) then
|
||||
local path = {}
|
||||
local fail_safe = 0
|
||||
for _ in pairs(closedSet) do
|
||||
fail_safe = fail_safe + 1
|
||||
end
|
||||
repeat
|
||||
if not closedSet[current_id] then return end
|
||||
table.insert(path, closedSet[current_id].pos)
|
||||
current_id = closedSet[current_id].parent
|
||||
until current_id == start_index or #path >= fail_safe
|
||||
if not closedSet[current_id] then self._path_data = {} return nil end
|
||||
table.insert(path, closedSet[current_id].pos)
|
||||
local reverse_path = {}
|
||||
repeat table.insert(reverse_path, table.remove(path)) until #path == 0
|
||||
self._path_data = {}
|
||||
return reverse_path
|
||||
end
|
||||
|
||||
count = count - 1
|
||||
|
||||
local adjacent = get_neighbors(current.pos, obj_width, obj_height, path_neighbors, openSet, closedSet)
|
||||
|
||||
-- Go through neighboring nodes
|
||||
for i = 1, #adjacent do
|
||||
local neighbor = {
|
||||
pos = adjacent[i],
|
||||
parent = current_id,
|
||||
gScore = 0,
|
||||
fScore = 0
|
||||
}
|
||||
local temp_gScore = current.gScore + get_distance_to_neighbor(current.pos, neighbor.pos)
|
||||
local new_gScore = 0
|
||||
if openSet[minetest.hash_node_position(neighbor.pos)] then
|
||||
new_gScore = openSet[minetest.hash_node_position(neighbor.pos)].gScore
|
||||
end
|
||||
if (temp_gScore < new_gScore
|
||||
or not openSet[minetest.hash_node_position(neighbor.pos)])
|
||||
and not closedSet[minetest.hash_node_position(neighbor.pos)] then
|
||||
if not openSet[minetest.hash_node_position(neighbor.pos)] then
|
||||
count = count + 1
|
||||
end
|
||||
local hCost = get_distance_to_neighbor(neighbor.pos, _goal)
|
||||
neighbor.gScore = temp_gScore
|
||||
neighbor.fScore = temp_gScore + hCost
|
||||
openSet[minetest.hash_node_position(neighbor.pos)] = neighbor
|
||||
end
|
||||
end
|
||||
if count > (max_open or 100) then
|
||||
self._path_data = {}
|
||||
return
|
||||
end
|
||||
end
|
||||
self._path_data = {}
|
||||
return nil
|
||||
end
|
||||
return find_path(start, goal)
|
||||
end
|
||||
|
||||
------------
|
||||
-- Theta* --
|
||||
------------
|
||||
|
||||
function creatura.find_theta_path(self, start, goal, obj_width, obj_height, max_open, climb, fly, swim)
|
||||
climb = climb or false
|
||||
fly = fly or false
|
||||
swim = swim or false
|
||||
|
||||
start = self._path_data.start or start
|
||||
|
||||
self._path_data.start = start
|
||||
|
||||
local path_neighbors = {
|
||||
{x = 1, y = 0, z = 0},
|
||||
{x = 0, y = 0, z = 1},
|
||||
{x = -1, y = 0, z = 0},
|
||||
{x = 0, y = 0, z = -1},
|
||||
}
|
||||
|
||||
if climb then
|
||||
table.insert(path_neighbors, {x = 0, y = 1, z = 0})
|
||||
end
|
||||
|
||||
if fly
|
||||
or swim then
|
||||
path_neighbors = {
|
||||
-- Central
|
||||
{x = 1, y = 0, z = 0},
|
||||
{x = 0, y = 0, z = 1},
|
||||
{x = -1, y = 0, z = 0},
|
||||
{x = 0, y = 0, z = -1},
|
||||
-- Directly Up or Down
|
||||
{x = 0, y = 1, z = 0},
|
||||
{x = 0, y = -1, z = 0}
|
||||
}
|
||||
end
|
||||
|
||||
local function get_neighbors(pos, width, height, tbl, open, closed)
|
||||
local result = {}
|
||||
for i = 1, #tbl do
|
||||
local neighbor = vector.add(pos, tbl[i])
|
||||
if neighbor.y == pos.y
|
||||
and not fly
|
||||
and not swim then
|
||||
neighbor = creatura.get_ground_level(neighbor, 1)
|
||||
end
|
||||
local can_move = get_line_of_sight({x = pos.x, y = neighbor.y, z = pos.z}, neighbor)
|
||||
if swim then
|
||||
can_move = true
|
||||
end
|
||||
if not moveable(vec_raise(neighbor, -0.49), width, height) then
|
||||
can_move = false
|
||||
if neighbor.y == pos.y
|
||||
and moveable(vec_raise(neighbor, 0.51), width, height) then
|
||||
neighbor = vec_raise(neighbor, 1)
|
||||
can_move = true
|
||||
end
|
||||
end
|
||||
if vector.equals(neighbor, goal) then
|
||||
can_move = true
|
||||
end
|
||||
if open[minetest.hash_node_position(neighbor)]
|
||||
or closed[minetest.hash_node_position(neighbor)] then
|
||||
can_move = false
|
||||
end
|
||||
if can_move
|
||||
and ((is_on_ground(neighbor)
|
||||
or (fly or swim))
|
||||
or (neighbor.x == pos.x
|
||||
and neighbor.z == pos.z
|
||||
and climb))
|
||||
and (not swim
|
||||
or creatura.get_node_def(neighbor).drawtype == "liquid") then
|
||||
table.insert(result, neighbor)
|
||||
end
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
local function find_path(_start, _goal)
|
||||
local us_time = minetest.get_us_time()
|
||||
|
||||
_start = {
|
||||
x = floor(_start.x + 0.5),
|
||||
y = floor(_start.y + 0.5),
|
||||
z = floor(_start.z + 0.5)
|
||||
}
|
||||
|
||||
_goal = {
|
||||
x = floor(_goal.x + 0.5),
|
||||
y = floor(_goal.y + 0.5),
|
||||
z = floor(_goal.z + 0.5)
|
||||
}
|
||||
|
||||
if _goal.x == _start.x
|
||||
and _goal.z == _start.z then -- No path can be found
|
||||
return nil
|
||||
end
|
||||
|
||||
local openSet = self._path_data.open or {}
|
||||
|
||||
local closedSet = self._path_data.closed or {}
|
||||
|
||||
local start_index = minetest.hash_node_position(_start)
|
||||
|
||||
openSet[start_index] = {
|
||||
pos = _start,
|
||||
parent = nil,
|
||||
gScore = 0,
|
||||
fScore = get_distance(_start, _goal)
|
||||
}
|
||||
|
||||
local count = self._path_data.count or 1
|
||||
|
||||
while count > 0 do
|
||||
if minetest.get_us_time() - us_time > theta_star_alloted_time then
|
||||
self._path_data = {
|
||||
start = _start,
|
||||
open = openSet,
|
||||
closed = closedSet,
|
||||
count = count
|
||||
}
|
||||
return
|
||||
end
|
||||
|
||||
-- Initialize ID and data
|
||||
local current_id, current = next(openSet)
|
||||
|
||||
-- Find lowest f cost
|
||||
for i, v in pairs(openSet) do
|
||||
if v.fScore < current.fScore then
|
||||
current_id = i
|
||||
current = v
|
||||
end
|
||||
end
|
||||
|
||||
-- Add lowest fScore to closedSet and remove from openSet
|
||||
openSet[current_id] = nil
|
||||
closedSet[current_id] = current
|
||||
|
||||
-- Reconstruct path if end is reached
|
||||
if (is_on_ground(_goal)
|
||||
and current_id == minetest.hash_node_position(_goal))
|
||||
or (not is_on_ground(_goal)
|
||||
and _goal.x == current.pos.x
|
||||
and _goal.z == current.pos.z) then
|
||||
local path = {}
|
||||
local fail_safe = 0
|
||||
for _ in pairs(closedSet) do
|
||||
fail_safe = fail_safe + 1
|
||||
end
|
||||
repeat
|
||||
if not closedSet[current_id] then return end
|
||||
table.insert(path, closedSet[current_id].pos)
|
||||
current_id = closedSet[current_id].parent
|
||||
until current_id == start_index or #path >= fail_safe
|
||||
if not closedSet[current_id] then self._path_data = {} return nil end
|
||||
table.insert(path, closedSet[current_id].pos)
|
||||
local reverse_path = {}
|
||||
repeat table.insert(reverse_path, table.remove(path)) until #path == 0
|
||||
self._path_data = {}
|
||||
return reverse_path
|
||||
end
|
||||
|
||||
count = count - 1
|
||||
|
||||
local adjacent = get_neighbors(current.pos, obj_width, obj_height, path_neighbors, openSet, closedSet)
|
||||
|
||||
-- Go through neighboring nodes
|
||||
for i = 1, #adjacent do
|
||||
local neighbor = {
|
||||
pos = adjacent[i],
|
||||
parent = current_id,
|
||||
gScore = 0,
|
||||
fScore = 0
|
||||
}
|
||||
if not openSet[minetest.hash_node_position(neighbor.pos)]
|
||||
and not closedSet[minetest.hash_node_position(neighbor.pos)] then
|
||||
local current_parent = closedSet[current.parent] or closedSet[start_index]
|
||||
if not current_parent then
|
||||
current_parent = openSet[current.parent] or openSet[start_index]
|
||||
end
|
||||
if current_parent
|
||||
and get_line_of_sight(current_parent.pos, neighbor.pos) then
|
||||
local temp_gScore = current_parent.gScore + get_distance_to_neighbor(current_parent.pos, neighbor.pos)
|
||||
local new_gScore = 999
|
||||
if openSet[minetest.hash_node_position(neighbor.pos)] then
|
||||
new_gScore = openSet[minetest.hash_node_position(neighbor.pos)].gScore
|
||||
end
|
||||
if temp_gScore < new_gScore then
|
||||
local hCost = get_distance_to_neighbor(neighbor.pos, _goal)
|
||||
neighbor.gScore = temp_gScore
|
||||
neighbor.fScore = temp_gScore + hCost
|
||||
neighbor.parent = minetest.hash_node_position(current_parent.pos)
|
||||
if openSet[minetest.hash_node_position(neighbor.pos)] then
|
||||
openSet[minetest.hash_node_position(neighbor.pos)] = nil
|
||||
end
|
||||
openSet[minetest.hash_node_position(neighbor.pos)] = neighbor
|
||||
count = count + 1
|
||||
end
|
||||
else
|
||||
local temp_gScore = current.gScore + get_distance_to_neighbor(current_parent.pos, neighbor.pos)
|
||||
local new_gScore = 999
|
||||
if openSet[minetest.hash_node_position(neighbor.pos)] then
|
||||
new_gScore = openSet[minetest.hash_node_position(neighbor.pos)].gScore
|
||||
end
|
||||
if temp_gScore < new_gScore then
|
||||
local hCost = get_distance_to_neighbor(neighbor.pos, _goal)
|
||||
neighbor.gScore = temp_gScore
|
||||
neighbor.fScore = temp_gScore + hCost
|
||||
if openSet[minetest.hash_node_position(neighbor.pos)] then
|
||||
openSet[minetest.hash_node_position(neighbor.pos)] = nil
|
||||
end
|
||||
openSet[minetest.hash_node_position(neighbor.pos)] = neighbor
|
||||
count = count + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if count > (max_open or 100) then
|
||||
self._path_data = {}
|
||||
return
|
||||
end
|
||||
end
|
||||
self._path_data = {}
|
||||
return nil
|
||||
end
|
||||
return find_path(start, goal)
|
||||
end
|
||||
Executable
+627
@@ -0,0 +1,627 @@
|
||||
-----------------
|
||||
-- Pathfinding --
|
||||
-----------------
|
||||
|
||||
local a_star_alloted_time = tonumber(minetest.settings:get("creatura_a_star_alloted_time")) or 500
|
||||
local theta_star_alloted_time = tonumber(minetest.settings:get("creatura_theta_star_alloted_time")) or 700
|
||||
|
||||
creatura.pathfinder = {}
|
||||
|
||||
local max_open = 300
|
||||
|
||||
-- Math
|
||||
|
||||
local floor = math.floor
|
||||
local abs = math.abs
|
||||
|
||||
local vec_add, vec_dist, vec_new, vec_round = vector.add, vector.distance, vector.new, vector.round
|
||||
|
||||
local function vec_raise(v, n)
|
||||
return {x = v.x, y = v.y + n, z = v.z}
|
||||
end
|
||||
|
||||
-- Heuristic
|
||||
|
||||
local function get_distance(start_pos, end_pos)
|
||||
local distX = abs(start_pos.x - end_pos.x)
|
||||
local distZ = abs(start_pos.z - end_pos.z)
|
||||
|
||||
if distX > distZ then
|
||||
return 14 * distZ + 10 * (distX - distZ)
|
||||
else
|
||||
return 14 * distX + 10 * (distZ - distX)
|
||||
end
|
||||
end
|
||||
|
||||
local function get_distance_to_neighbor(start_pos, end_pos)
|
||||
local distX = abs(start_pos.x - end_pos.x)
|
||||
local distY = abs(start_pos.y - end_pos.y)
|
||||
local distZ = abs(start_pos.z - end_pos.z)
|
||||
|
||||
if distX > distZ then
|
||||
return (14 * distZ + 10 * (distX - distZ)) * (distY + 1)
|
||||
else
|
||||
return (14 * distX + 10 * (distZ - distX)) * (distY + 1)
|
||||
end
|
||||
end
|
||||
|
||||
-- Blocked Movement Checks
|
||||
|
||||
local is_blocked = creatura.is_blocked
|
||||
|
||||
local function get_line_of_sight(a, b)
|
||||
local steps = floor(vec_dist(a, b))
|
||||
local line = {}
|
||||
|
||||
for i = 0, steps do
|
||||
local pos
|
||||
|
||||
if steps > 0 then
|
||||
pos = {
|
||||
x = a.x + (b.x - a.x) * (i / steps),
|
||||
y = a.y + (b.y - a.y) * (i / steps),
|
||||
z = a.z + (b.z - a.z) * (i / steps)
|
||||
}
|
||||
else
|
||||
pos = a
|
||||
end
|
||||
table.insert(line, pos)
|
||||
end
|
||||
|
||||
if #line < 1 then
|
||||
return false
|
||||
else
|
||||
for i = 1, #line do
|
||||
local node = minetest.get_node(line[i])
|
||||
if creatura.get_node_def(node.name).walkable then
|
||||
return false
|
||||
end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function is_on_ground(pos)
|
||||
local ground = {
|
||||
x = pos.x,
|
||||
y = pos.y - 1,
|
||||
z = pos.z
|
||||
}
|
||||
if creatura.get_node_def(ground).walkable then
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Neighbor Check Grids
|
||||
|
||||
local neighbor_grid = {
|
||||
{x = 1, y = 0, z = 0},
|
||||
{x = 1, y = 0, z = 1},
|
||||
{x = 0, y = 0, z = 1},
|
||||
{x = -1, y = 0, z = 1},
|
||||
{x = -1, y = 0, z = 0},
|
||||
{x = -1, y = 0, z = -1},
|
||||
{x = 0, y = 0, z = -1},
|
||||
{x = 1, y = 0, z = -1}
|
||||
}
|
||||
|
||||
local neighbor_grid_climb = {
|
||||
{x = 1, y = 0, z = 0},
|
||||
{x = 1, y = 0, z = 1},
|
||||
{x = 0, y = 0, z = 1},
|
||||
{x = -1, y = 0, z = 1},
|
||||
{x = -1, y = 0, z = 0},
|
||||
{x = -1, y = 0, z = -1},
|
||||
{x = 0, y = 0, z = -1},
|
||||
{x = 1, y = 0, z = -1},
|
||||
|
||||
{x = 0, y = 1, z = 0},
|
||||
{x = 0, y = -1, z = 0}
|
||||
}
|
||||
|
||||
local neighbor_grid_3d = {
|
||||
-- Central
|
||||
{x = 1, y = 0, z = 0},
|
||||
{x = 0, y = 0, z = 1},
|
||||
{x = -1, y = 0, z = 0},
|
||||
{x = 0, y = 0, z = -1},
|
||||
-- Directly Up or Down
|
||||
{x = 0, y = 1, z = 0},
|
||||
{x = 0, y = -1, z = 0}
|
||||
}
|
||||
|
||||
-- Get Neighbors
|
||||
|
||||
local function get_neighbors(pos, width, height, open, closed, parent, evaluated)
|
||||
local result = {}
|
||||
local neighbor
|
||||
local can_move
|
||||
local hashed_pos
|
||||
local step
|
||||
|
||||
for i = 1, #neighbor_grid do
|
||||
neighbor = vec_add(pos, neighbor_grid[i])
|
||||
can_move = get_line_of_sight({x = pos.x, y = neighbor.y, z = pos.z}, neighbor)
|
||||
hashed_pos = minetest.hash_node_position(neighbor)
|
||||
|
||||
if parent
|
||||
and vec_dist(parent, neighbor) < vec_dist(pos, neighbor) then
|
||||
can_move = false
|
||||
end
|
||||
|
||||
if open[hashed_pos]
|
||||
or closed[hashed_pos]
|
||||
or evaluated[hashed_pos] then
|
||||
can_move = false
|
||||
elseif can_move then
|
||||
can_move = not is_blocked(neighbor, width, height)
|
||||
|
||||
if not can_move then -- Step Up
|
||||
step = vec_raise(neighbor, 1)
|
||||
can_move = not is_blocked(vec_round(step), width, height)
|
||||
neighbor = vec_round(step)
|
||||
else
|
||||
step = creatura.get_ground_level(vec_new(neighbor), 1)
|
||||
if step.y < neighbor.y
|
||||
and not is_blocked(vec_round(step), width, height) then
|
||||
neighbor = step
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if can_move then
|
||||
table.insert(result, neighbor)
|
||||
end
|
||||
|
||||
evaluated[hashed_pos] = true
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
function creatura.pathfinder.get_neighbors_climb(pos, width, height, open, closed)
|
||||
local result = {}
|
||||
local neighbor
|
||||
local can_move
|
||||
local hashed_pos
|
||||
local step
|
||||
|
||||
for i = 1, #neighbor_grid_climb do
|
||||
neighbor = vec_add(pos, neighbor_grid_climb[i])
|
||||
can_move = get_line_of_sight({x = pos.x, y = neighbor.y, z = pos.z}, neighbor)
|
||||
hashed_pos = minetest.hash_node_position(neighbor)
|
||||
|
||||
if open[hashed_pos]
|
||||
or closed[hashed_pos] then
|
||||
can_move = false
|
||||
elseif can_move then
|
||||
can_move = not is_blocked(neighbor, width, height)
|
||||
|
||||
if not can_move then -- Step Up
|
||||
step = vec_raise(neighbor, 1)
|
||||
can_move = not is_blocked(vec_round(step), width, height)
|
||||
neighbor = vec_round(step)
|
||||
elseif i < 9 then
|
||||
step = creatura.get_ground_level(vec_new(neighbor), 1)
|
||||
if step.y < neighbor.y
|
||||
and not is_blocked(vec_round(step), width, height) then
|
||||
neighbor = step
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if can_move then
|
||||
table.insert(result, neighbor)
|
||||
end
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
function creatura.pathfinder.get_neighbors_fly(pos, width, height, open, closed, parent)
|
||||
local result = {}
|
||||
local neighbor
|
||||
local can_move
|
||||
local hashed_pos
|
||||
|
||||
for i = 1, #neighbor_grid_3d do
|
||||
neighbor = vec_add(pos, neighbor_grid_3d[i])
|
||||
can_move = get_line_of_sight({x = pos.x, y = pos.y, z = pos.z}, neighbor)
|
||||
hashed_pos = minetest.hash_node_position(neighbor)
|
||||
|
||||
if parent
|
||||
and vec_dist(parent, neighbor) < vec_dist(pos, neighbor) then
|
||||
can_move = false
|
||||
end
|
||||
|
||||
if open[hashed_pos]
|
||||
or closed[hashed_pos] then
|
||||
can_move = false
|
||||
elseif can_move then
|
||||
can_move = not is_blocked(neighbor, width, height)
|
||||
end
|
||||
|
||||
if can_move then
|
||||
table.insert(result, neighbor)
|
||||
end
|
||||
end
|
||||
return result, true
|
||||
end
|
||||
|
||||
function creatura.pathfinder.get_neighbors_swim(pos, width, height, open, closed, parent)
|
||||
local result = {}
|
||||
local neighbor
|
||||
local can_move
|
||||
local hashed_pos
|
||||
|
||||
for i = 1, #neighbor_grid_3d do
|
||||
neighbor = vec_add(pos, neighbor_grid_3d[i])
|
||||
can_move = get_line_of_sight({x = pos.x, y = pos.y, z = pos.z}, neighbor)
|
||||
hashed_pos = minetest.hash_node_position(neighbor)
|
||||
|
||||
if (parent
|
||||
and vec_dist(parent, neighbor) < vec_dist(pos, neighbor))
|
||||
or creatura.get_node_def(neighbor).drawtype ~= "liquid" then
|
||||
can_move = false
|
||||
end
|
||||
|
||||
if open[hashed_pos]
|
||||
or closed[hashed_pos] then
|
||||
can_move = false
|
||||
elseif can_move then
|
||||
can_move = not is_blocked(neighbor, width, height)
|
||||
end
|
||||
|
||||
if can_move then
|
||||
table.insert(result, neighbor)
|
||||
end
|
||||
end
|
||||
return result, true
|
||||
end
|
||||
|
||||
-- A*
|
||||
|
||||
function creatura.pathfinder.find_path(self, pos1, pos2, neighbor_func)
|
||||
local us_time = minetest.get_us_time()
|
||||
local check_vertical = false
|
||||
neighbor_func = neighbor_func or get_neighbors
|
||||
|
||||
local start = self._path_data.start or {
|
||||
x = floor(pos1.x + 0.5),
|
||||
y = floor(pos1.y + 0.5),
|
||||
z = floor(pos1.z + 0.5)
|
||||
}
|
||||
local goal = {
|
||||
x = floor(pos2.x + 0.5),
|
||||
y = floor(pos2.y + 0.5),
|
||||
z = floor(pos2.z + 0.5)
|
||||
}
|
||||
|
||||
self._path_data.start = start
|
||||
|
||||
if goal.x == start.x
|
||||
and goal.z == start.z then -- No path can be found
|
||||
self._path_data = {}
|
||||
return
|
||||
end
|
||||
|
||||
local openSet = self._path_data.open or {}
|
||||
local closedSet = self._path_data.closed or {}
|
||||
local evaluated = {}
|
||||
|
||||
local start_index = minetest.hash_node_position(start)
|
||||
|
||||
openSet[start_index] = {
|
||||
pos = start,
|
||||
parent = nil,
|
||||
gScore = 0,
|
||||
fScore = get_distance(start, goal)
|
||||
}
|
||||
|
||||
local count = self._path_data.count or 1
|
||||
local current_id, current
|
||||
local adjacent
|
||||
local neighbor
|
||||
|
||||
local temp_gScore
|
||||
local new_gScore
|
||||
local hCost
|
||||
|
||||
local hashed_pos
|
||||
local parent_open
|
||||
local parent_closed
|
||||
|
||||
while count > 0 do
|
||||
|
||||
-- Initialize ID and data
|
||||
current_id, current = next(openSet)
|
||||
|
||||
-- Find lowest f cost
|
||||
for i, v in pairs(openSet) do
|
||||
if v.fScore < current.fScore then
|
||||
current_id = i
|
||||
current = v
|
||||
end
|
||||
end
|
||||
|
||||
if not current_id then self._path_data = {} return end -- failsafe
|
||||
|
||||
-- Add lowest fScore to closedSet and remove from openSet
|
||||
openSet[current_id] = nil
|
||||
closedSet[current_id] = current
|
||||
|
||||
if ((check_vertical or is_on_ground(goal))
|
||||
and current_id == minetest.hash_node_position(goal))
|
||||
or ((not check_vertical and not is_on_ground(goal))
|
||||
and goal.x == current.pos.x
|
||||
and goal.z == current.pos.z) then
|
||||
local path = {}
|
||||
local fail_safe = 0
|
||||
|
||||
for _ in pairs(closedSet) do
|
||||
fail_safe = fail_safe + 1
|
||||
end
|
||||
|
||||
repeat
|
||||
if not closedSet[current_id] then self._path_data = {} return end
|
||||
table.insert(path, closedSet[current_id].pos)
|
||||
current_id = closedSet[current_id].parent
|
||||
until current_id == start_index or #path >= fail_safe
|
||||
|
||||
if not closedSet[current_id] then self._path_data = {} return nil end
|
||||
|
||||
table.insert(path, closedSet[current_id].pos)
|
||||
|
||||
local reverse_path = {}
|
||||
repeat table.insert(reverse_path, table.remove(path)) until #path == 0
|
||||
|
||||
self._path_data = {}
|
||||
return reverse_path
|
||||
end
|
||||
|
||||
parent_open = openSet[current.parent]
|
||||
parent_closed = closedSet[current.parent]
|
||||
adjacent, check_vertical = neighbor_func(
|
||||
current.pos,
|
||||
self.width,
|
||||
self.height,
|
||||
openSet,
|
||||
closedSet,
|
||||
(parent_closed and parent_closed.pos) or (parent_open and parent_open.pos),
|
||||
evaluated
|
||||
)
|
||||
-- Fly, Swim, and Climb all return true for check_vertical to properly check if goal has been reached
|
||||
|
||||
-- Go through neighboring nodes
|
||||
for i = 1, #adjacent do
|
||||
neighbor = {
|
||||
pos = adjacent[i],
|
||||
parent = current_id,
|
||||
gScore = 0,
|
||||
fScore = 0
|
||||
}
|
||||
|
||||
temp_gScore = current.gScore + get_distance_to_neighbor(current.pos, neighbor.pos)
|
||||
new_gScore = 0
|
||||
|
||||
hashed_pos = minetest.hash_node_position(neighbor.pos)
|
||||
|
||||
if openSet[hashed_pos] then
|
||||
new_gScore = openSet[hashed_pos].gScore
|
||||
end
|
||||
|
||||
if (temp_gScore < new_gScore
|
||||
or not openSet[hashed_pos])
|
||||
and not closedSet[hashed_pos] then
|
||||
if not openSet[hashed_pos] then
|
||||
count = count + 1
|
||||
end
|
||||
|
||||
hCost = get_distance_to_neighbor(neighbor.pos, goal)
|
||||
|
||||
neighbor.gScore = temp_gScore
|
||||
neighbor.fScore = temp_gScore + hCost
|
||||
openSet[hashed_pos] = neighbor
|
||||
end
|
||||
end
|
||||
|
||||
if minetest.get_us_time() - us_time > a_star_alloted_time then
|
||||
self._path_data = {
|
||||
start = start,
|
||||
open = openSet,
|
||||
closed = closedSet,
|
||||
count = count
|
||||
}
|
||||
return
|
||||
end
|
||||
|
||||
if count > (max_open or 100) then
|
||||
self._path_data = {}
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Theta*
|
||||
|
||||
function creatura.pathfinder.find_path_theta(self, pos1, pos2, neighbor_func)
|
||||
local us_time = minetest.get_us_time()
|
||||
local check_vertical = false
|
||||
neighbor_func = neighbor_func or get_neighbors
|
||||
|
||||
local start = self._path_data.start or {
|
||||
x = floor(pos1.x + 0.5),
|
||||
y = floor(pos1.y + 0.5),
|
||||
z = floor(pos1.z + 0.5)
|
||||
}
|
||||
local goal = {
|
||||
x = floor(pos2.x + 0.5),
|
||||
y = floor(pos2.y + 0.5),
|
||||
z = floor(pos2.z + 0.5)
|
||||
}
|
||||
|
||||
self._path_data.start = start
|
||||
|
||||
if goal.x == start.x
|
||||
and goal.z == start.z then -- No path can be found
|
||||
return
|
||||
end
|
||||
|
||||
local openSet = self._path_data.open or {}
|
||||
local closedSet = self._path_data.closed or {}
|
||||
local evaluated = {}
|
||||
|
||||
local start_index = minetest.hash_node_position(start)
|
||||
|
||||
openSet[start_index] = {
|
||||
pos = start,
|
||||
parent = nil,
|
||||
gScore = 0,
|
||||
fScore = get_distance(start, goal)
|
||||
}
|
||||
|
||||
local count = self._path_data.count or 1
|
||||
local current_id, current
|
||||
local current_parent
|
||||
local adjacent
|
||||
local neighbor
|
||||
|
||||
local temp_gScore
|
||||
local new_gScore
|
||||
local hCost
|
||||
|
||||
local hashed_pos
|
||||
local parent_open
|
||||
local parent_closed
|
||||
|
||||
while count > 0 do
|
||||
|
||||
-- Initialize ID and data
|
||||
current_id, current = next(openSet)
|
||||
|
||||
-- Find lowest f cost
|
||||
for i, v in pairs(openSet) do
|
||||
if v.fScore < current.fScore then
|
||||
current_id = i
|
||||
current = v
|
||||
end
|
||||
end
|
||||
|
||||
if not current_id then return end -- failsafe
|
||||
|
||||
-- Add lowest fScore to closedSet and remove from openSet
|
||||
openSet[current_id] = nil
|
||||
closedSet[current_id] = current
|
||||
|
||||
if ((check_vertical or is_on_ground(goal))
|
||||
and current_id == minetest.hash_node_position(goal))
|
||||
or ((not check_vertical and not is_on_ground(goal))
|
||||
and goal.x == current.pos.x
|
||||
and goal.z == current.pos.z) then
|
||||
local path = {}
|
||||
local fail_safe = 0
|
||||
|
||||
for _ in pairs(closedSet) do
|
||||
fail_safe = fail_safe + 1
|
||||
end
|
||||
|
||||
repeat
|
||||
if not closedSet[current_id] then return end
|
||||
table.insert(path, closedSet[current_id].pos)
|
||||
current_id = closedSet[current_id].parent
|
||||
until current_id == start_index or #path >= fail_safe
|
||||
|
||||
if not closedSet[current_id] then self._path_data = {} return nil end
|
||||
|
||||
table.insert(path, closedSet[current_id].pos)
|
||||
|
||||
local reverse_path = {}
|
||||
repeat table.insert(reverse_path, table.remove(path)) until #path == 0
|
||||
|
||||
self._path_data = {}
|
||||
return reverse_path
|
||||
end
|
||||
|
||||
parent_open = openSet[current.parent]
|
||||
parent_closed = closedSet[current.parent]
|
||||
adjacent, check_vertical = neighbor_func(
|
||||
current.pos,
|
||||
self.width,
|
||||
self.height,
|
||||
openSet,
|
||||
closedSet,
|
||||
(parent_closed and parent_closed.pos) or (parent_open and parent_open.pos),
|
||||
evaluated
|
||||
)
|
||||
-- Fly, Swim, and Climb all return true for check_vertical to properly check if goal has been reached
|
||||
|
||||
-- Go through neighboring nodes
|
||||
for i = 1, #adjacent do
|
||||
neighbor = {
|
||||
pos = adjacent[i],
|
||||
parent = current_id,
|
||||
gScore = 0,
|
||||
fScore = 0
|
||||
}
|
||||
|
||||
hashed_pos = minetest.hash_node_position(neighbor.pos)
|
||||
|
||||
if not openSet[hashed_pos]
|
||||
and not closedSet[hashed_pos] then
|
||||
current_parent = closedSet[current.parent] or closedSet[start_index]
|
||||
if not current_parent then
|
||||
current_parent = openSet[current.parent] or openSet[start_index]
|
||||
end
|
||||
|
||||
if current_parent
|
||||
and get_line_of_sight(current_parent.pos, neighbor.pos) then
|
||||
temp_gScore = current_parent.gScore + get_distance_to_neighbor(current_parent.pos, neighbor.pos)
|
||||
new_gScore = 999
|
||||
|
||||
if openSet[hashed_pos] then
|
||||
new_gScore = openSet[hashed_pos].gScore
|
||||
end
|
||||
|
||||
if temp_gScore < new_gScore then
|
||||
hCost = get_distance_to_neighbor(neighbor.pos, goal)
|
||||
neighbor.gScore = temp_gScore
|
||||
neighbor.fScore = temp_gScore + hCost
|
||||
neighbor.parent = minetest.hash_node_position(current_parent.pos)
|
||||
openSet[hashed_pos] = neighbor
|
||||
count = count + 1
|
||||
end
|
||||
else
|
||||
temp_gScore = current.gScore + get_distance_to_neighbor(current_parent.pos, neighbor.pos)
|
||||
new_gScore = 999
|
||||
|
||||
if openSet[hashed_pos] then
|
||||
new_gScore = openSet[hashed_pos].gScore
|
||||
end
|
||||
|
||||
if temp_gScore < new_gScore then
|
||||
hCost = get_distance_to_neighbor(neighbor.pos, goal)
|
||||
neighbor.gScore = temp_gScore
|
||||
neighbor.fScore = temp_gScore + hCost
|
||||
|
||||
openSet[hashed_pos] = neighbor
|
||||
count = count + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if minetest.get_us_time() - us_time > theta_star_alloted_time then
|
||||
self._path_data = {
|
||||
start = start,
|
||||
open = openSet,
|
||||
closed = closedSet,
|
||||
count = count
|
||||
}
|
||||
return
|
||||
end
|
||||
|
||||
if count > (max_open or 100) then
|
||||
self._path_data = {}
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
# How mobs step up nodes.
|
||||
#
|
||||
# - Simple means mobs use Minetests builtin stepping.
|
||||
# - Fancy means mobs will step up nodes with a quick hop but can cause lag.
|
||||
creatura_step_type (Step Type) enum simple simple,fancy
|
||||
|
||||
# How often (in seconds) the spawn ABM is called
|
||||
creatura_spawn_interval (Spawn ABM Interval) int 10
|
||||
|
||||
# Allows Mobs to spawn during chunk generation (If dependent mods use spawn_on_gen)
|
||||
creatura_mapgen_spawning (Mapgen Spawning) bool true
|
||||
|
||||
# How many chunks are generated before a Mob can spawn
|
||||
creatura_mapgen_spawn_interval (Mapgen Spawning Interval) int 64
|
||||
|
||||
# How many Mobs can be a in a Mapblock before ABM spawning is blocked
|
||||
creatura_mapblock_limit (Max Mobs per Mapblock) int 12
|
||||
|
||||
# How many Mobs can be within Active Block Send Range of an attempted spawn before stopping attempt
|
||||
creatura_abr_limit (Max Mobs within ABR) int 24
|
||||
|
||||
# Minimum distance to a player for ABM Spawning
|
||||
creatura_min_abm_dist (Minimum ABM Spawning Distance) int 32
|
||||
|
||||
# Allows Mobs to spawn in protected areas
|
||||
creatura_protected_spawn (Protected Area Spawning) bool true
|
||||
|
||||
# Allotted time (in μs) per step for A* pathfinding (lower means less lag but slower pathfinding)
|
||||
creatura_a_star_alloted_time (A* Pathfinding Alloted time per step) int 500
|
||||
|
||||
# Allotted time (in μs) per step for Theta* pathfinding (lower means less lag but slower pathfinding)
|
||||
creatura_theta_star_alloted_time (Theta* Pathfinding Alloted time per step) int 700
|
||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
+573
@@ -0,0 +1,573 @@
|
||||
--------------
|
||||
-- Spawning --
|
||||
--------------
|
||||
|
||||
creatura.registered_mob_spawns = {}
|
||||
creatura.registered_on_spawns = {}
|
||||
|
||||
-- Math --
|
||||
|
||||
local abs = math.abs
|
||||
local ceil = math.ceil
|
||||
local pi = math.pi
|
||||
local random = math.random
|
||||
local min = math.min
|
||||
|
||||
local vec_add, vec_dist, vec_sub = vector.add, vector.distance, vector.subtract
|
||||
|
||||
-- Utility Functions --
|
||||
|
||||
local function format_name(str)
|
||||
if str then
|
||||
if str:match(":") then str = str:split(":")[2] end
|
||||
return (string.gsub(" " .. str, "%W%l", string.upper):sub(2):gsub("_", " "))
|
||||
end
|
||||
end
|
||||
|
||||
local function table_contains(tbl, val)
|
||||
for _, v in pairs(tbl) do
|
||||
if v == val then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function pos_meets_params(pos, def)
|
||||
if not minetest.find_nodes_in_area(pos, pos, def.nodes) then return false end
|
||||
if not minetest.find_node_near(pos, 1, def.neighbors) then return false end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
local function can_spawn(pos, width, height)
|
||||
local pos2
|
||||
local w_iter = width / ceil(width)
|
||||
for y = 0, height, height / ceil(height) do
|
||||
for z = -width, width, w_iter do
|
||||
for x = -width, width, w_iter do
|
||||
pos2 = {x = pos.x + x, y = pos.y + y, z = pos.z + z}
|
||||
local def = creatura.get_node_def(pos2)
|
||||
if def.walkable then return false end
|
||||
end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function do_on_spawn(pos, obj)
|
||||
local name = obj and obj:get_luaentity().name
|
||||
if not name then return end
|
||||
local spawn_functions = creatura.registered_on_spawns[name] or {}
|
||||
|
||||
if #spawn_functions > 0 then
|
||||
for _, func in ipairs(spawn_functions) do
|
||||
func(obj:get_luaentity(), pos)
|
||||
if not obj:get_yaw() then break end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
----------------
|
||||
-- Spawn Item --
|
||||
----------------
|
||||
|
||||
local creative = minetest.settings:get_bool("creative_mode")
|
||||
|
||||
function creatura.register_spawn_item(name, def)
|
||||
local inventory_image
|
||||
if not def.inventory_image
|
||||
and ((def.col1 and def.col2)
|
||||
or (def.hex_primary and def.hex_secondary)) then
|
||||
local primary = def.col1 or def.hex_primary
|
||||
local secondary = def.col2 or def.hex_secondary
|
||||
local base = "(creatura_spawning_crystal_primary.png^[multiply:#" .. primary .. ")"
|
||||
local spots = "(creatura_spawning_crystal_secondary.png^[multiply:#" .. secondary .. ")"
|
||||
inventory_image = base .. "^" .. spots
|
||||
end
|
||||
local mod_name = name:split(":")[1]
|
||||
local mob_name = name:split(":")[2]
|
||||
def.description = def.description or "Spawn " .. format_name(name)
|
||||
def.inventory_image = def.inventory_image or inventory_image
|
||||
def.on_place = function(itemstack, player, pointed_thing)
|
||||
-- If the player right-clicks something like a chest or item frame then
|
||||
-- run the node's on_rightclick callback
|
||||
local under = pointed_thing.under
|
||||
local node = minetest.get_node(under)
|
||||
local node_def = minetest.registered_nodes[node.name]
|
||||
if node_def and node_def.on_rightclick and
|
||||
not (player and player:is_player() and
|
||||
player:get_player_control().sneak) then
|
||||
return node_def.on_rightclick(under, node, player, itemstack,
|
||||
pointed_thing) or itemstack
|
||||
end
|
||||
|
||||
-- Otherwise spawn the mob
|
||||
local pos = minetest.get_pointed_thing_position(pointed_thing, true)
|
||||
if minetest.is_protected(pos, player and player:get_player_name() or "") then return end
|
||||
local mobdef = minetest.registered_entities[name]
|
||||
local spawn_offset = abs(mobdef.collisionbox[2])
|
||||
pos.y = (pos.y - 0.49) + spawn_offset
|
||||
if def.antispam then
|
||||
local objs = minetest.get_objects_in_area(vec_sub(pos, 0.51), vec_add(pos, 0.51))
|
||||
for _, obj in ipairs(objs) do
|
||||
if obj
|
||||
and obj:get_luaentity()
|
||||
and obj:get_luaentity().name == name then
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
local object = minetest.add_entity(pos, name)
|
||||
if object then
|
||||
object:set_yaw(random(0, pi * 2))
|
||||
object:get_luaentity().last_yaw = object:get_yaw()
|
||||
if def.on_spawn then
|
||||
def.on_spawn(object:get_luaentity(), player)
|
||||
end
|
||||
end
|
||||
if not minetest.is_creative_enabled(player:get_player_name())
|
||||
or def.consume_in_creative then
|
||||
itemstack:take_item()
|
||||
return itemstack
|
||||
end
|
||||
end
|
||||
minetest.register_craftitem(def.itemstring or (mod_name .. ":spawn_" .. mob_name), def)
|
||||
end
|
||||
|
||||
function creatura.register_on_spawn(name, func)
|
||||
if not creatura.registered_on_spawns[name] then
|
||||
creatura.registered_on_spawns[name] = {}
|
||||
end
|
||||
table.insert(creatura.registered_on_spawns[name], func)
|
||||
end
|
||||
|
||||
--------------
|
||||
-- Spawning --
|
||||
--------------
|
||||
|
||||
--[[creatura.register_abm_spawn("mymod:mymob", {
|
||||
chance = 3000,
|
||||
interval = 30,
|
||||
min_height = 0,
|
||||
max_height = 128,
|
||||
min_light = 1,
|
||||
max_light = 15,
|
||||
min_group = 1,
|
||||
max_group = 4,
|
||||
nodes = {"group:soil", "group:stone"},
|
||||
neighbors = {"air"},
|
||||
spawn_on_load = false,
|
||||
spawn_in_nodes = false,
|
||||
spawn_cap = 5
|
||||
})]]
|
||||
|
||||
local protected_spawn = minetest.settings:get_bool("creatura_protected_spawn", true)
|
||||
local abr = (tonumber(minetest.get_mapgen_setting("active_block_range")) or 4) * 16
|
||||
local max_per_block = tonumber(minetest.settings:get("creatura_mapblock_limit")) or 12
|
||||
local max_in_abr = tonumber(minetest.settings:get("creatura_abr_limit")) or 24
|
||||
local min_abm_dist = min(abr / 2, tonumber(minetest.settings:get("creatura_min_abm_dist")) or 32)
|
||||
|
||||
local mobs_spawn = minetest.settings:get_bool("mobs_spawn") ~= false
|
||||
|
||||
local mapgen_mobs = {}
|
||||
|
||||
function creatura.register_abm_spawn(mob, def)
|
||||
local chance = def.chance or 3000
|
||||
local interval = def.interval or 30
|
||||
local min_height = def.min_height or 0
|
||||
local max_height = def.max_height or 128
|
||||
local min_time = def.min_time or 0
|
||||
local max_time = def.max_time or 24000
|
||||
local min_light = def.min_light or 1
|
||||
local max_light = def.max_light or 15
|
||||
local min_group = def.min_group or 1
|
||||
local max_group = def.max_group or 4
|
||||
local block_protected = def.block_protected_spawn or false
|
||||
local biomes = def.biomes or {}
|
||||
local nodes = def.nodes or {"group:soil", "group:stone"}
|
||||
local neighbors = def.neighbors or {"air"}
|
||||
local spawn_on_load = def.spawn_on_load or false
|
||||
local spawn_in_nodes = def.spawn_in_nodes or false
|
||||
local spawn_cap = def.spawn_cap or 5
|
||||
|
||||
local function spawn_func(pos, aocw)
|
||||
|
||||
if not mobs_spawn then
|
||||
return
|
||||
end
|
||||
|
||||
if not spawn_in_nodes then
|
||||
pos.y = pos.y + 1
|
||||
end
|
||||
|
||||
if (not protected_spawn
|
||||
or block_protected)
|
||||
and minetest.is_protected(pos, "") then
|
||||
return
|
||||
end
|
||||
|
||||
local tod = (minetest.get_timeofday() or 0) * 24000
|
||||
|
||||
local bounds_in = tod >= min_time and tod <= max_time
|
||||
local bounds_ex = tod >= max_time or tod <= min_time
|
||||
|
||||
if (max_time > min_time and not bounds_in)
|
||||
or (min_time > max_time and not bounds_ex) then
|
||||
return
|
||||
end
|
||||
|
||||
local light = minetest.get_node_light(pos) or 7
|
||||
|
||||
if light > max_light
|
||||
or light < min_light then
|
||||
return
|
||||
end
|
||||
|
||||
if aocw
|
||||
and aocw >= max_per_block then
|
||||
return
|
||||
end
|
||||
|
||||
if biomes
|
||||
and #biomes > 0 then
|
||||
local biome_id = minetest.get_biome_data(pos).biome
|
||||
local biome_name = minetest.get_biome_name(biome_id)
|
||||
local is_spawn_biome = false
|
||||
for _, biome in ipairs(biomes) do
|
||||
if biome:match("^" .. biome_name) then
|
||||
is_spawn_biome = true
|
||||
break
|
||||
end
|
||||
end
|
||||
if not is_spawn_biome then return end
|
||||
end
|
||||
|
||||
local mob_count = 0
|
||||
local plyr_found = false
|
||||
|
||||
local objects = minetest.get_objects_inside_radius(pos, abr)
|
||||
|
||||
for _, object in ipairs(objects) do
|
||||
local ent = object:get_luaentity()
|
||||
if ent
|
||||
and ent.name == mob then
|
||||
mob_count = mob_count + 1
|
||||
if mob_count > spawn_cap
|
||||
or mob_count > max_in_abr then
|
||||
return
|
||||
end
|
||||
end
|
||||
if object:is_player() then
|
||||
plyr_found = true
|
||||
if vec_dist(pos, object:get_pos()) < min_abm_dist then
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if not plyr_found then
|
||||
return
|
||||
end
|
||||
|
||||
local mob_def = minetest.registered_entities[mob]
|
||||
local mob_width = mob_def.collisionbox[4]
|
||||
local mob_height = mob_def.collisionbox[5]
|
||||
|
||||
if not can_spawn(pos, mob_width, mob_height) then
|
||||
return
|
||||
end
|
||||
|
||||
local group_size = random(min_group or 1, max_group or 1)
|
||||
local obj
|
||||
|
||||
if group_size > 1 then
|
||||
local offset
|
||||
local spawn_pos
|
||||
for _ = 1, group_size do
|
||||
offset = ceil(mob_width)
|
||||
spawn_pos = creatura.get_ground_level({
|
||||
x = pos.x + random(-offset, offset),
|
||||
y = pos.y,
|
||||
z = pos.z + random(-offset, offset)
|
||||
}, 3)
|
||||
if not can_spawn(spawn_pos, mob_width, mob_height) then
|
||||
spawn_pos = pos
|
||||
end
|
||||
obj = minetest.add_entity(spawn_pos, mob)
|
||||
do_on_spawn(spawn_pos, obj)
|
||||
end
|
||||
else
|
||||
obj = minetest.add_entity(pos, mob)
|
||||
do_on_spawn(pos, obj)
|
||||
end
|
||||
|
||||
minetest.log("action",
|
||||
"[Creatura] [ABM Spawning] Spawned " .. group_size .. " " .. mob .. " at " .. minetest.pos_to_string(pos))
|
||||
|
||||
end
|
||||
|
||||
minetest.register_abm({
|
||||
label = mob .. " spawning",
|
||||
nodenames = nodes,
|
||||
neighbors = neighbors,
|
||||
interval = interval,
|
||||
chance = chance,
|
||||
min_y = min_height,
|
||||
max_y = max_height,
|
||||
catch_up = false,
|
||||
action = function(pos, _, _, aocw)
|
||||
spawn_func(pos, aocw)
|
||||
end
|
||||
})
|
||||
|
||||
if spawn_on_load then
|
||||
table.insert(mapgen_mobs, mob)
|
||||
end
|
||||
|
||||
creatura.registered_mob_spawns[mob] = {
|
||||
chance = def.chance or 3000,
|
||||
interval = def.interval or 30,
|
||||
min_height = def.min_height or 0,
|
||||
max_height = def.max_height or 128,
|
||||
min_time = def.min_time or 0,
|
||||
max_time = def.max_time or 24000,
|
||||
min_light = def.min_light or 1,
|
||||
max_light = def.max_light or 15,
|
||||
min_group = def.min_group or 1,
|
||||
max_group = def.max_group or 4,
|
||||
block_protected = def.block_protected_spawn or false,
|
||||
biomes = def.biomes or {},
|
||||
nodes = def.nodes or {"group:soil", "group:stone"},
|
||||
neighbors = def.neighbors or {"air"},
|
||||
spawn_on_load = def.spawn_on_load or false,
|
||||
spawn_in_nodes = def.spawn_in_nodes or false,
|
||||
spawn_cap = def.spawn_cap or 5
|
||||
}
|
||||
end
|
||||
|
||||
----------------
|
||||
-- DEPRECATED --
|
||||
----------------
|
||||
|
||||
|
||||
-- Mapgen --
|
||||
|
||||
minetest.register_node("creatura:spawn_node", {
|
||||
drawtype = "airlike",
|
||||
groups = {not_in_creative_inventory = 1},
|
||||
walkable = false
|
||||
})
|
||||
|
||||
local mapgen_spawning = false
|
||||
local mapgen_spawning_int = tonumber(minetest.settings:get("creatura_mapgen_spawn_interval")) or 64
|
||||
|
||||
if mapgen_spawning then
|
||||
local chunk_delay = 0
|
||||
local c_air = minetest.get_content_id("air")
|
||||
local c_spawn = minetest.get_content_id("creatura:spawn_node")
|
||||
|
||||
minetest.register_on_generated(function(minp, maxp)
|
||||
if chunk_delay > 0 then chunk_delay = chunk_delay - 1 end
|
||||
local meta_queue = {}
|
||||
|
||||
local vm, emin, emax = minetest.get_mapgen_object("voxelmanip")
|
||||
local area = VoxelArea:new{MinEdge = emin, MaxEdge = emax}
|
||||
local data = vm:get_data()
|
||||
|
||||
local min_x, max_x = minp.x, maxp.x
|
||||
local min_y, max_y = minp.y, maxp.y
|
||||
local min_z, max_z = minp.z, maxp.z
|
||||
|
||||
local def
|
||||
local center
|
||||
|
||||
local current_biome
|
||||
local spawn_biomes
|
||||
|
||||
local current_pos
|
||||
|
||||
for _, mob_name in ipairs(mapgen_mobs) do
|
||||
local mob_spawned = false
|
||||
|
||||
def = creatura.registered_mob_spawns[mob_name]
|
||||
|
||||
center = {
|
||||
x = min_x + (max_x - min_x) * 0.5,
|
||||
y = min_y + (max_y - min_y) * 0.5,
|
||||
z = min_z + (max_z - min_z) * 0.5
|
||||
}
|
||||
|
||||
current_biome = minetest.get_biome_name(minetest.get_biome_data(center).biome)
|
||||
spawn_biomes = def.biomes
|
||||
|
||||
if not mob_spawned
|
||||
and (not spawn_biomes
|
||||
or table_contains(spawn_biomes, current_biome)) then
|
||||
for z = min_z + 8, max_z - 7, 8 do
|
||||
if mob_spawned then break end
|
||||
for x = min_x + 8, max_x - 7, 8 do
|
||||
if mob_spawned then break end
|
||||
for y = min_y, max_y do
|
||||
local vi = area:index(x, y, z)
|
||||
|
||||
if data[vi] == c_air
|
||||
or data[vi] == c_spawn then
|
||||
break
|
||||
end
|
||||
|
||||
-- Check if position is outside of vertical bounds
|
||||
if y > def.max_height
|
||||
or y < def.min_height then
|
||||
break
|
||||
end
|
||||
|
||||
current_pos = vector.new(x, y, z)
|
||||
|
||||
-- Check if position has required nodes
|
||||
if not pos_meets_params(current_pos, def) then
|
||||
break
|
||||
end
|
||||
|
||||
if def.spawn_in_nodes then
|
||||
-- Add Spawn Node to Map
|
||||
data[vi] = c_spawn
|
||||
|
||||
local group_size = random(def.min_group or 1, def.max_group or 1)
|
||||
table.insert(meta_queue, {pos = current_pos, mob = mob_name, cluster = group_size})
|
||||
|
||||
mob_spawned = true
|
||||
break
|
||||
elseif data[area:index(x, y + 1, z)] == c_air then
|
||||
vi = area:index(x, y + 1, z)
|
||||
current_pos = vector.new(x, y + 1, z)
|
||||
|
||||
-- Add Spawn Node to Map
|
||||
data[vi] = c_spawn
|
||||
|
||||
local group_size = random(def.min_group or 1, def.max_group or 1)
|
||||
table.insert(meta_queue, {pos = current_pos, mob = mob_name, cluster = group_size})
|
||||
|
||||
mob_spawned = true
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if #meta_queue > 0 then
|
||||
vm:set_data(data)
|
||||
vm:write_to_map()
|
||||
|
||||
for _, unset_meta in ipairs(meta_queue) do
|
||||
local pos = unset_meta.pos
|
||||
local mob = unset_meta.mob
|
||||
local cluster = unset_meta.cluster
|
||||
|
||||
local meta = minetest.get_meta(pos)
|
||||
meta:set_string("mob", mob)
|
||||
meta:set_int("cluster", cluster)
|
||||
end
|
||||
|
||||
chunk_delay = mapgen_spawning_int
|
||||
end
|
||||
end)
|
||||
|
||||
local spawn_interval = tonumber(minetest.settings:get("creatura_spawn_interval")) or 10
|
||||
|
||||
minetest.register_abm({
|
||||
label = "Creatura Spawning",
|
||||
nodenames = {"creatura:spawn_node"},
|
||||
interval = spawn_interval,
|
||||
chance = 1,
|
||||
action = function(pos)
|
||||
local plyr_found = false
|
||||
local objects = minetest.get_objects_inside_radius(pos, abr)
|
||||
|
||||
for _, object in ipairs(objects) do
|
||||
if object:is_player() then
|
||||
plyr_found = true
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if not plyr_found then return end
|
||||
|
||||
local meta = minetest.get_meta(pos)
|
||||
local name = meta:get_string("mob") or ""
|
||||
if name == "" then minetest.remove_node(pos) return end
|
||||
local amount = meta:get_int("cluster")
|
||||
local obj
|
||||
if amount > 0 then
|
||||
for _ = 1, amount do
|
||||
obj = minetest.add_entity(pos, name)
|
||||
do_on_spawn(pos, obj)
|
||||
end
|
||||
minetest.log("action",
|
||||
"[Creatura] Spawned " .. amount .. " " .. name .. " at " .. minetest.pos_to_string(pos))
|
||||
else
|
||||
obj = minetest.add_entity(pos, name)
|
||||
do_on_spawn(pos, obj)
|
||||
minetest.log("action",
|
||||
"[Creatura] Spawned a " .. name .. " at " .. minetest.pos_to_string(pos))
|
||||
end
|
||||
minetest.remove_node(pos)
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
function creatura.register_mob_spawn(name, def)
|
||||
local spawn_def = {
|
||||
chance = def.chance or 5,
|
||||
min_height = def.min_height or 0,
|
||||
max_height = def.max_height or 128,
|
||||
min_time = def.min_time or 0,
|
||||
max_time = def.max_time or 24000,
|
||||
min_light = def.min_light or 6,
|
||||
max_light = def.max_light or 15,
|
||||
min_group = def.min_group or 1,
|
||||
max_group = def.max_group or 4,
|
||||
nodes = def.nodes or nil,
|
||||
biomes = def.biomes or nil,
|
||||
--spawn_cluster = def.spawn_cluster or false,
|
||||
spawn_on_load = def.spawn_on_gen or false,
|
||||
spawn_in_nodes = def.spawn_in_nodes or false,
|
||||
spawn_cap = def.spawn_cap or 5,
|
||||
--send_debug = def.send_debug or false
|
||||
}
|
||||
--creatura.registered_mob_spawns[name] = spawn_def
|
||||
|
||||
creatura.register_abm_spawn(name, spawn_def)
|
||||
end
|
||||
|
||||
function creatura.register_spawn_egg(name, col1, col2, inventory_image)
|
||||
if col1 and col2 then
|
||||
local base = "(creatura_spawning_crystal_primary.png^[multiply:#" .. col1 .. ")"
|
||||
local spots = "(creatura_spawning_crystal_secondary.png^[multiply:#" .. col2 .. ")"
|
||||
inventory_image = base .. "^" .. spots
|
||||
end
|
||||
local mod_name = name:split(":")[1]
|
||||
local mob_name = name:split(":")[2]
|
||||
minetest.register_craftitem(mod_name .. ":spawn_" .. mob_name, {
|
||||
description = "Spawn " .. format_name(name),
|
||||
inventory_image = inventory_image,
|
||||
stack_max = 99,
|
||||
on_place = function(itemstack, _, pointed_thing)
|
||||
local mobdef = minetest.registered_entities[name]
|
||||
local spawn_offset = abs(mobdef.collisionbox[2])
|
||||
local pos = minetest.get_pointed_thing_position(pointed_thing, true)
|
||||
pos.y = (pos.y - 0.4) + spawn_offset
|
||||
local object = minetest.add_entity(pos, name)
|
||||
if object then
|
||||
object:set_yaw(random(1, 6))
|
||||
object:get_luaentity().last_yaw = object:get_yaw()
|
||||
end
|
||||
if not creative then
|
||||
itemstack:take_item()
|
||||
return itemstack
|
||||
end
|
||||
end
|
||||
})
|
||||
end
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 698 B |
+20
-1
@@ -11,7 +11,7 @@ return function(hearth, api, register_node, construction)
|
||||
for i, name in ipairs(list) do out[i] = node(name) end
|
||||
return out
|
||||
end
|
||||
M.species_order = {"oak", "birch", "pine", "willow", "alder", "beech", "aspen", "rowan", "hazel", "spruce"}
|
||||
M.species_order = {"oak", "birch", "pine", "willow", "alder", "beech", "aspen", "rowan", "hazel", "spruce", "crab_apple", "wild_cherry", "field_maple"}
|
||||
M.rocks = {"granite", "basalt", "limestone", "slate", "sandstone"}
|
||||
-- Moisture/fertility/drainage are normalized habitat preferences, NOT player
|
||||
-- climate values. The succession owner decides how to consume these hints.
|
||||
@@ -69,6 +69,15 @@ return function(hearth, api, register_node, construction)
|
||||
spruce = {height=10, width=5, crown="spire", heat={0,40}, humidity={40,85},
|
||||
y_min=8, y_max=185, soils={"podzol", "forest_floor", "alpine_turf"},
|
||||
biomes={"hearth_pine_taiga", "hearth_birch_forest"}, shade=0.85, litter=0.5},
|
||||
crab_apple = {height=6,width=7,crown="round",heat={35,75},humidity={30,75},
|
||||
y_min=3,y_max=100,soils={"grass","forest_floor","soil"},
|
||||
biomes={"hearth_temperate","hearth_oak_woodland"},shade=0.65,litter=0.7},
|
||||
wild_cherry = {height=9,width=7,crown="horizontal",heat={35,75},humidity={30,70},
|
||||
y_min=4,y_max=120,soils={"grass","forest_floor","soil"},
|
||||
biomes={"hearth_temperate","hearth_oak_woodland"},shade=0.6,litter=0.8},
|
||||
field_maple = {height=7,width=9,crown="lobed_broad",heat={35,80},humidity={25,75},
|
||||
y_min=3,y_max=110,soils={"grass","forest_floor","soil"},
|
||||
biomes={"hearth_temperate","hearth_oak_woodland"},shade=0.8,litter=0.8},
|
||||
}
|
||||
function M.supports_species(species, surface)
|
||||
local p = M.profiles[species]
|
||||
@@ -122,6 +131,16 @@ return function(hearth, api, register_node, construction)
|
||||
elseif species == "spruce" then
|
||||
local radius = y >= 1 and math.min(2,math.floor((h-1-y)/3)) or -1
|
||||
leaf = r <= radius and dx+dz <= radius+1
|
||||
elseif species == "crab_apple" then
|
||||
leaf = y>=2 and dx*dx+dz*dz <= (y==h-1 and 2 or (y==2 and 5 or 10))
|
||||
trunk = trunk or (y==2 and dx+dz<=2 and (dx==0 or dz==0))
|
||||
elseif species == "wild_cherry" then
|
||||
leaf = y>=4 and r<=(y==h-1 and 1 or 3) and dx+dz<=4 and (y%2==0 or r<=1)
|
||||
trunk = trunk or ((y==4 or y==6) and dz==0 and dx<=2)
|
||||
elseif species == "field_maple" then
|
||||
leaf = y>=2 and r<=(y==h-1 and 2 or 4) and dx+dz<=(y==h-1 and 3 or 5)
|
||||
and not (dx==dz and r>=2)
|
||||
trunk = trunk or (y==3 and dx+dz<=3 and (dx==0 or dz==0))
|
||||
end
|
||||
local stem = canonical and "log" or species .. "_log"
|
||||
local leaves = canonical and "leaves" or species .. "_leaves"
|
||||
|
||||
+72
-12
@@ -54,6 +54,9 @@ return function(hearth, api)
|
||||
add("rowan","Rowan","Sorbus aucuparia","acid",grow,"pollinator,browse,nursery")
|
||||
add("hazel","Common hazel","Corylus avellana","wood",grow,"browse,ectomycorrhiza,nursery")
|
||||
add("spruce","Norway spruce","Picea abies","acid",year,"ectomycorrhiza,nursery,resin")
|
||||
add("crab_apple","Crab apple","Malus sylvestris","wood",grow,"pollinator,browse,nursery")
|
||||
add("wild_cherry","Wild cherry","Prunus avium","wood",grow,"pollinator,browse,nursery")
|
||||
add("field_maple","Field maple","Acer campestre","wood",grow,"pollinator,browse,nursery")
|
||||
group="shrubs"
|
||||
add("bilberry","Bilberry","Vaccinium myrtillus","acid",grow,"pollinator,browse,nursery",{food=true})
|
||||
add("blackberry","Pleated blackberry","Rubus plicatus","shrub",grow,"pollinator,browse,nursery",{food=true})
|
||||
@@ -65,6 +68,10 @@ return function(hearth, api)
|
||||
add("holly","European holly","Ilex aquifolium","wood",year,"pollinator,browse,nursery",{toxic=true})
|
||||
add("juniper","Common juniper","Juniperus communis","acid",year,"browse,nursery,resin")
|
||||
add("guelder_rose","Guelder rose","Viburnum opulus","wetwood",grow,"pollinator,browse,nursery",{toxic=true})
|
||||
add("honeysuckle","Honeysuckle","Lonicera periclymenum","shrub",grow,"pollinator,nursery",{toxic=true})
|
||||
add("ivy","Ivy","Hedera helix","shade",year,"pollinator,nursery",{toxic=true})
|
||||
add("gorse","Gorse","Ulex europaeus","acid",year,"nitrogenfixer,pollinator,nursery",{toxic=true})
|
||||
add("broom","Broom","Cytisus scoparius","acid",grow,"nitrogenfixer,pollinator,nursery",{toxic=true})
|
||||
group="grasses"
|
||||
add("sedge","Greater tussock-sedge","Carex paniculata","bank",grow,"browse,engineer,nursery",{existing=true})
|
||||
add("bulrush","Bulrush (great reedmace)","Typha latifolia","bank",grow,"engineer,nursery",{existing=true})
|
||||
@@ -87,6 +94,18 @@ return function(hearth, api)
|
||||
add("wild_garlic","Wild garlic","Allium ursinum","shade","Spring","pollinator")
|
||||
add("cowslip","Cowslip","Primula veris","meadow","Spring","pollinator")
|
||||
add("wood_anemone","Wood anemone","Anemone nemorosa","shade","Spring","pollinator",{toxic=true})
|
||||
add("dandelion","Dandelion","Taraxacum officinale","meadow",grow,"pollinator,browse")
|
||||
add("daisy","Daisy","Bellis perennis","meadow",grow,"pollinator")
|
||||
add("red_clover","Red clover","Trifolium pratense","meadow",grow,"nitrogenfixer,pollinator,browse")
|
||||
add("birdsfoot_trefoil","Bird's-foot trefoil","Lotus corniculatus","meadow",grow,"nitrogenfixer,pollinator,browse",{toxic=true})
|
||||
add("ribwort_plantain","Ribwort plantain","Plantago lanceolata","meadow",grow,"browse")
|
||||
add("selfheal","Selfheal","Prunella vulgaris","meadow",grow,"pollinator")
|
||||
add("knapweed","Common knapweed","Centaurea nigra","meadow",grow,"pollinator,nursery")
|
||||
add("oxeye_daisy","Oxeye daisy","Leucanthemum vulgare","meadow",grow,"pollinator")
|
||||
add("field_scabious","Field scabious","Knautia arvensis","meadow",grow,"pollinator")
|
||||
add("teasel","Wild teasel","Dipsacus fullonum","meadow",grow,"pollinator,nursery")
|
||||
add("cow_parsley","Cow parsley","Anthriscus sylvestris","shrub",grow,"pollinator,nursery")
|
||||
add("garlic_mustard","Garlic mustard","Alliaria petiolata","shade",grow,"pollinator,nursery")
|
||||
group="ferns_mosses"
|
||||
add("bracken","Bracken","Pteridium aquilinum","acid",grow,"nursery,allelopathy",{toxic=true})
|
||||
add("lady_fern","Lady-fern","Athyrium filix-femina","shade",grow,"nursery")
|
||||
@@ -98,6 +117,7 @@ return function(hearth, api)
|
||||
add("moss","Cypress-leaved plait-moss","Hypnum cupressiforme","moss",year,"engineer,nursery",{existing=true})
|
||||
add("fork_moss","Broom fork-moss","Dicranum scoparium","moss",year,"nursery")
|
||||
add("star_moss","Swan's-neck thyme-moss","Mnium hornum","moss",year,"nursery")
|
||||
add("holly_fern","Soft shield-fern (contract ID holly fern)","Polystichum setiferum","shade",year,"nursery")
|
||||
group="fungi"
|
||||
add("fly_agaric","Fly agaric","Amanita muscaria","litter","Summer,Autumn","ectomycorrhiza",{toxic=true})
|
||||
add("birch_bolete","Brown birch bolete","Leccinum scabrum","litter","Summer,Autumn","ectomycorrhiza")
|
||||
@@ -137,6 +157,16 @@ return function(hearth, api)
|
||||
niche("rowan",{light={5,15},moisture={.3,.85}})
|
||||
niche("spruce",{moisture={.4,.85},light={4,15}})
|
||||
niche("hazel",{ph={5,8}})
|
||||
niche("crab_apple",{light={7,15},moisture={.3,.75},ph={5,8}})
|
||||
niche("wild_cherry",{light={7,15},moisture={.3,.7},ph={5.5,8}})
|
||||
niche("field_maple",{light={5,15},moisture={.25,.75},ph={5.5,8}})
|
||||
niche("gorse",{light={10,15},moisture={.2,.65},ph={3.5,6.5}})
|
||||
niche("broom",{light={9,15},moisture={.2,.65},ph={4,7}})
|
||||
niche("ivy",{light={3,15},moisture={.3,.85},ph={5,8}})
|
||||
niche("birdsfoot_trefoil",{moisture={.2,.7},ph={5,8}})
|
||||
niche("ribwort_plantain",{moisture={.2,.8}})
|
||||
niche("field_scabious",{moisture={.2,.65},ph={6,8}})
|
||||
niche("holly_fern",{light={3,10},moisture={.5,.9},ph={5,8}})
|
||||
niche("juniper",{ph={4,8},moisture={.15,.6}})
|
||||
niche("nettle",{light={5,15},nutrients=1})
|
||||
niche("cowslip",{ph={6,8},moisture={.2,.65}})
|
||||
@@ -171,8 +201,21 @@ return function(hearth, api)
|
||||
bluebell="Spring",yarrow="Summer,Autumn",foxglove="Summer",meadowsweet="Summer",
|
||||
wild_garlic="Spring",cowslip="Spring",wood_anemone="Spring",yellow_waterlily="Summer",
|
||||
white_waterlily="Summer",water_crowfoot="Spring,Summer",water_mint="Summer,Autumn",
|
||||
water_forgetmenot="Spring,Summer",brooklime="Spring,Summer"}
|
||||
water_forgetmenot="Spring,Summer",brooklime="Spring,Summer",
|
||||
crab_apple="Spring",wild_cherry="Spring",field_maple="Spring",
|
||||
dandelion="Spring,Summer,Autumn",daisy="Spring,Summer,Autumn",red_clover="Summer,Autumn",
|
||||
birdsfoot_trefoil="Summer",selfheal="Summer,Autumn",knapweed="Summer,Autumn",
|
||||
oxeye_daisy="Summer",field_scabious="Summer,Autumn",teasel="Summer",
|
||||
cow_parsley="Spring,Summer",garlic_mustard="Spring",honeysuckle="Summer,Autumn",
|
||||
ivy="Autumn",gorse="Spring,Summer,Autumn,Winter",broom="Spring,Summer"}
|
||||
for id,text in pairs(flowers) do index[id].flowering=set(text) end
|
||||
-- Seed-season windows are qualitative phenology, not permission to replay
|
||||
-- every missed crop of seeds. Individual maturity is checked by succession.
|
||||
for _,r in ipairs(records) do
|
||||
r.seeding=set(r.group=="trees" and "Autumn" or "Summer,Autumn")
|
||||
if r.id=="ivy" then r.seeding=set("Spring") end
|
||||
if r.id=="garlic_mustard" or r.id=="cow_parsley" then r.seeding=set("Summer") end
|
||||
end
|
||||
for _,id in ipairs({"bilberry","blackberry","raspberry"}) do
|
||||
index[id].fruiting=set(id=="blackberry" and "Summer,Autumn" or "Summer")
|
||||
end
|
||||
@@ -199,7 +242,7 @@ return function(hearth, api)
|
||||
function B.kind(value)
|
||||
local k=raw_kind(value)
|
||||
if index[k] then return k end
|
||||
local id,part=(k or ""):match("^([a-z]+)_([a-z]+)$")
|
||||
local id,part=(k or ""):match("^([a-z_]+)_([a-z]+)$")
|
||||
if index[id] and index[id].group=="trees" and
|
||||
({seedling=true,young=true,mature=true,leaves=true,log=true,planks=true})[part] then return id end
|
||||
end
|
||||
@@ -235,8 +278,9 @@ return function(hearth, api)
|
||||
end
|
||||
end
|
||||
function B.pollinator(value,season)
|
||||
local r=record(value); return r~=nil and r.tags.pollinator==true and r.flowering[season]==true
|
||||
local r=record(value); return r~=nil and r.tags.pollinator==true and r.flowering~=nil and r.flowering[season]==true
|
||||
end
|
||||
function B.seeding(value,season) local r=record(value); return r~=nil and r.seeding[season]==true end
|
||||
function B.edible(value,season)
|
||||
local r=record(value); return r~=nil and r.food==true and not r.toxic and r.fruiting[season]==true
|
||||
end
|
||||
@@ -285,10 +329,11 @@ return function(hearth, api)
|
||||
end
|
||||
end
|
||||
local DAY=86400
|
||||
local YEAR=365.2425*DAY -- shared with succession.frugivore; resource policy, not lifespan
|
||||
local function now()
|
||||
local n
|
||||
if api.now then n=api.now() else n=os.time() end
|
||||
if not finite(n) or n<0 or n>9007199254740991-DAY then return nil end
|
||||
if not finite(n) or n<0 or n>9007199254740991-YEAR then return nil end
|
||||
return math.floor(n)
|
||||
end
|
||||
function B.harvest(pos,player)
|
||||
@@ -309,17 +354,32 @@ return function(hearth, api)
|
||||
local stored=meta:get_string("botany_ready_at")
|
||||
local ready=0
|
||||
if stored~="" then ready=tonumber(stored) end
|
||||
if not finite(ready) or ready<0 then return false,"metadata" end
|
||||
if not finite(ready) or ready<0 or ready>9007199254740991 then return false,"metadata" end
|
||||
if ready>time then return false,"cooldown" end
|
||||
local pollen_stored=meta:get_string("succ_pollinated_until")
|
||||
local pollen=pollen_stored=="" and 0 or tonumber(pollen_stored)
|
||||
-- Match succession.pollinate's exact expiry and one-day future bound.
|
||||
if not finite(pollen) or pollen<0 or pollen>time+DAY then return false,"metadata" end
|
||||
local amount=2+(pollen>time and 1 or 0)
|
||||
local inv=player:get_inventory()
|
||||
if not inv or not inv:room_for_item("main","hearth:berries 2") then return false,"inventory" end
|
||||
-- Lock before inventory callbacks: reentrant harvest cannot duplicate fruit.
|
||||
meta:set_string("botany_ready_at",tostring(time+DAY))
|
||||
local leftover=inv:add_item("main","hearth:berries 2")
|
||||
if not inv then return false,"inventory" end
|
||||
-- Reserve both budgets before even room_for_item: callbacks may reenter.
|
||||
-- Pollination modifies an existing standing crop; it never creates fruit
|
||||
-- on an empty/cooling bush and is a boolean bonus, not doubled inventory.
|
||||
meta:set_string("botany_ready_at",tostring(time+YEAR))
|
||||
meta:set_string("succ_pollinated_until","0")
|
||||
local function rollback()
|
||||
meta:set_string("botany_ready_at",stored)
|
||||
meta:set_string("succ_pollinated_until",pollen_stored)
|
||||
end
|
||||
local item="hearth:berries "..amount
|
||||
if not inv:room_for_item("main",item) then rollback(); return false,"inventory" end
|
||||
local leftover=inv:add_item("main",item)
|
||||
local count=leftover and leftover:get_count()
|
||||
if not finite(count) or count<0 or count>2 then return false,"inventory" end
|
||||
if count==2 then meta:set_string("botany_ready_at",stored); return false,"inventory" end
|
||||
return true,2-count
|
||||
-- An invalid delivery result cannot safely be retried: keep the lock.
|
||||
if not finite(count) or count%1~=0 or count<0 or count>amount then return false,"inventory" end
|
||||
if count==amount then rollback(); return false,"inventory" end
|
||||
return true,amount-count
|
||||
end
|
||||
local registered=false
|
||||
function B.register_nodes(register_node)
|
||||
|
||||
+17
-12
@@ -3,19 +3,19 @@ return function(hearth, api)
|
||||
local C = {VERSION = 1, MAX_JSON = 2048, FORM = "hearth:character"}
|
||||
local key = "hearth:appearance"
|
||||
local options = {
|
||||
body = {"compact", "balanced", "broad"},
|
||||
body = {"compact", "lean", "balanced", "broad", "sturdy"},
|
||||
skin = {"porcelain", "sand", "honey", "olive", "umber", "ebony"},
|
||||
face = {"calm", "angular", "soft", "weathered"},
|
||||
face = {"calm", "angular", "soft", "weathered", "lined"},
|
||||
eyes = {"hazel", "brown", "green", "blue", "grey"},
|
||||
hair = {"cropped", "sidepart", "wavy", "braids", "long", "shaved", "none"},
|
||||
hair = {"cropped", "sidepart", "wavy", "braids", "long", "shaved", "none", "curly", "topknot", "undercut"},
|
||||
hairColor = {"ash", "flax", "chestnut", "auburn", "black", "silver"},
|
||||
beard = {"none", "stubble", "moustache", "short_beard", "full_beard"},
|
||||
garment = {"tunic", "workshirt", "jerkin", "coat"},
|
||||
beard = {"none", "stubble", "moustache", "short_beard", "full_beard", "goatee", "braided_beard"},
|
||||
garment = {"tunic", "workshirt", "jerkin", "coat", "hooded_cloak", "travel_coat", "wool_mantle"},
|
||||
cloth = {"sage", "teal", "slate", "mauve", "ochre", "rust", "linen", "charcoal"},
|
||||
trousers = {"earth", "slate", "moss", "charcoal"}, boots = {"walnut", "black", "tan"},
|
||||
accent = {"none", "scarf", "apron", "sash", "vest"},
|
||||
accent = {"none", "scarf", "apron", "sash", "vest", "satchel", "tool_belt", "pauldrons", "hood"},
|
||||
accentColor = {"sage", "teal", "slate", "mauve", "ochre", "rust", "linen", "charcoal"},
|
||||
detail = {"none", "freckles", "scar", "freckles_scar"},
|
||||
detail = {"none", "freckles", "scar", "freckles_scar", "birthmark"},
|
||||
}
|
||||
local order = {"body", "skin", "face", "eyes", "hair", "hairColor", "beard", "garment",
|
||||
"cloth", "trousers", "boots", "accent", "accentColor", "detail"}
|
||||
@@ -83,8 +83,8 @@ return function(hearth, api)
|
||||
local function name(stem) return "hearth_char_" .. stem .. ".png" end
|
||||
local texture = table.concat({name("skin_" .. p.skin), name("face_" .. p.face), name("eyes_" .. p.eyes),
|
||||
name("garment_" .. p.garment .. "_" .. p.cloth), name("trousers_" .. p.trousers), name("boots_" .. p.boots),
|
||||
name("accent_" .. p.accent .. "_" .. p.accentColor), name("hair_" .. p.hair .. "_" .. p.hairColor),
|
||||
name("beard_" .. p.beard .. "_" .. p.hairColor), name("detail_" .. p.detail)}, "^")
|
||||
name("hair_" .. p.hair .. "_" .. p.hairColor), name("beard_" .. p.beard .. "_" .. p.hairColor),
|
||||
name("accent_" .. p.accent .. "_" .. p.accentColor), name("detail_" .. p.detail)}, "^")
|
||||
return "hearth_person" .. (p.body == "balanced" and "" or "_" .. p.body) .. ".b3d", texture
|
||||
end
|
||||
local function publish(player, value)
|
||||
@@ -120,7 +120,12 @@ return function(hearth, api)
|
||||
hair = "Hair style", hairColor = "Hair & facial hair color", beard = "Facial hair", garment = "Garment",
|
||||
cloth = "Cloth color", trousers = "Trousers", boots = "Boot leather", accent = "Accent",
|
||||
accentColor = "Accent color", detail = "Face details"}
|
||||
local names = {sidepart = "Side part", workshirt = "Work shirt", freckles_scar = "Freckles and scar"}
|
||||
local names = {sidepart = "Side part", workshirt = "Work shirt", freckles_scar = "Freckles and scar",
|
||||
hooded_cloak = "Hooded cloak", travel_coat = "Travel coat", wool_mantle = "Wool mantle",
|
||||
tool_belt = "Tool belt", braided_beard = "Braided beard", body="Body shape",
|
||||
compact="Compact", lean="Lean", balanced="Balanced", broad="Broad", sturdy="Sturdy",
|
||||
curly="Curly", topknot="Topknot", undercut="Undercut", goatee="Goatee",
|
||||
birthmark="Birthmark", pauldrons="Shoulder guards"}
|
||||
local function title(id)
|
||||
return names[id] or (id:gsub("_", " "):gsub("^%l", string.upper))
|
||||
end
|
||||
@@ -146,7 +151,7 @@ return function(hearth, api)
|
||||
"model[0.65,1.65;5.1,6.4;traveler;" .. esc(mesh) .. ";" .. esc(texture)
|
||||
.. ";0," .. st.yaw .. ";true;true;0,19;12]",
|
||||
"button[0.7,8.25;1.55,0.6;front;Front]button[2.4,8.25;1.55,0.6;profile;Profile]button[4.1,8.25;1.55,0.6;back;Back]",
|
||||
"tooltip[traveler;Drag to turn the preview. Only this model rotates.]"}
|
||||
"tooltip[traveler;" .. esc("Drag to turn the preview. Only this model rotates.") .. "]"}
|
||||
for i, tab in ipairs(tabs) do
|
||||
f[#f+1] = "button[" .. (6.2 + (i-1)*1.8) .. ",1.65;1.7,0.65;tab_" .. i .. ";" .. tab[1] .. "]"
|
||||
end
|
||||
@@ -184,7 +189,7 @@ return function(hearth, api)
|
||||
for k, v in pairs(fields) do
|
||||
count = count + 1; if count > 20 then return false end
|
||||
if visible[k] then
|
||||
local n = type(v) == "string" and #v <= 1 and v:match("^[1-9]$") and tonumber(v)
|
||||
local n = type(v) == "string" and #v <= 2 and v:match("^[1-9][0-9]?$") and tonumber(v)
|
||||
if not n or not options[visible[k]][n] then return false end
|
||||
draft[visible[k]] = options[visible[k]][n]
|
||||
elseif not controls[k] or (type(v) ~= "string" and type(v) ~= "boolean")
|
||||
|
||||
@@ -25,6 +25,8 @@ local api = setmetatable({
|
||||
end,
|
||||
}, {__index = core})
|
||||
local modpath = api.get_modpath("hearth")
|
||||
-- Plain policy namespace, available before any factory captures calendar APIs.
|
||||
hearth.lifecycle = dofile(modpath .. "/lifecycle.lua")
|
||||
local construction = dofile(modpath .. "/construction.lua")(hearth, api)
|
||||
local seasons = dofile(modpath .. "/seasons.lua")(hearth, api)
|
||||
hearth.seasons = seasons
|
||||
@@ -796,4 +798,9 @@ botany.register_nodes(register_node)
|
||||
-- Species leaves and canonical ecological cubes/sprites must precede variants.
|
||||
seasons.register()
|
||||
|
||||
-- Creatura's MIT-licensed boid and ambient-spawn APIs are optional at runtime;
|
||||
-- the managed package pins and bundles the ContentDB 0.2.10 release.
|
||||
if type(creatura) == "table" then hearth.creatura = creatura end
|
||||
hearth.wildlife = dofile(modpath .. "/wildlife.lua")(hearth, api)
|
||||
-- Wildlife owns factory loading; publish that exact catalogue, not a second one.
|
||||
hearth.fauna = hearth.wildlife.fauna
|
||||
|
||||
+33
-11
@@ -1,6 +1,6 @@
|
||||
-- Species/niche policy only. Succession owns the one fixed-eight scheduler.
|
||||
return function(hearth, api)
|
||||
local D = {species_order={"oak","birch","pine","willow","alder","beech","aspen","rowan","hazel","spruce"},
|
||||
local D = {species_order={"oak","birch","pine","willow","alder","beech","aspen","rowan","hazel","spruce","crab_apple","wild_cherry","field_maple"},
|
||||
plants={"clover","nettle","heather","bluebell","sedge","bulrush","moss","lichen"},
|
||||
grounds={grass=true,soil=true,forest_floor=true,podzol=true,alluvium=true,peat=true,alpine_turf=true},
|
||||
rocks={stone=true,granite=true,basalt=true,limestone=true,slate=true,sandstone=true,scree=true}}
|
||||
@@ -14,19 +14,23 @@ return function(hearth, api)
|
||||
for _, plant in ipairs(D.plants) do flora[plant]=true end
|
||||
function D.tree_info(value)
|
||||
local k=kind(value)
|
||||
local s,stage=(k or ""):match("^([a-z]+)_([a-z]+)$")
|
||||
local s,stage=(k or ""):match("^([a-z_]+)_([a-z]+)$")
|
||||
if species[s] and ({seedling=true,young=true,mature=true})[stage] then return s,stage end
|
||||
end
|
||||
function D.family(value)
|
||||
local k=kind(value)
|
||||
local s,stage=D.tree_info(k)
|
||||
if s then return ({seedling="tree_seedling",young="young_tree",mature="mature_tree"})[stage] end
|
||||
local wood,part=(k or ""):match("^([a-z]+)_(leaves)$")
|
||||
local wood,part=(k or ""):match("^([a-z_]+)_(leaves)$")
|
||||
if species[wood] then return part end
|
||||
wood,part=(k or ""):match("^([a-z]+)_(log)$")
|
||||
wood,part=(k or ""):match("^([a-z_]+)_(log)$")
|
||||
return species[wood] and part or k
|
||||
end
|
||||
function D.is_worldgen_flora(value) return flora[kind(value)]==true or D.tree_info(value)~=nil end
|
||||
function D.is_worldgen_flora(value)
|
||||
return flora[kind(value)]==true or D.tree_info(value)~=nil
|
||||
or (hearth.botany and hearth.botany.kind(value)~=nil and D.family(value)~="log" and D.family(value)~="leaves"
|
||||
and not (kind(value) or ""):match("_planks$")) or false
|
||||
end
|
||||
function D.is_ground(value) return D.grounds[kind(value)]==true end
|
||||
function D.is_rock(value) return D.rocks[kind(value)]==true end
|
||||
function D.supports_ground(value, support)
|
||||
@@ -39,7 +43,9 @@ return function(hearth, api)
|
||||
alder={alluvium=true,peat=true,forest_floor=true},
|
||||
beech={grass=true,soil=true,forest_floor=true}, aspen={grass=true,forest_floor=true,alluvium=true},
|
||||
rowan={grass=true,forest_floor=true,podzol=true,alpine_turf=true},
|
||||
hazel={grass=true,soil=true,forest_floor=true}, spruce={podzol=true,forest_floor=true,alpine_turf=true}}
|
||||
hazel={grass=true,soil=true,forest_floor=true}, spruce={podzol=true,forest_floor=true,alpine_turf=true},
|
||||
crab_apple={grass=true,soil=true,forest_floor=true},wild_cherry={grass=true,soil=true,forest_floor=true},
|
||||
field_maple={grass=true,soil=true,forest_floor=true}}
|
||||
function D.supports_species(s, support)
|
||||
if not species[s] then return false end
|
||||
local b=hearth.biomes
|
||||
@@ -86,9 +92,9 @@ return function(hearth, api)
|
||||
choices=(h.ground=="peat" or (p.x+p.z)%2~=0) and {"alder","willow"} or {"willow","alder"}
|
||||
elseif h.ground=="forest_floor" and (h.canopy or 0)>=0.5 then choices={"oak","birch"}
|
||||
else choices={"birch","oak"} end
|
||||
-- Retain the old pioneer niches at the origin while admitting all ten
|
||||
-- Retain the old pioneer niches at the origin while admitting all thirteen
|
||||
-- species deterministically; no random reroll or extra world scan.
|
||||
local extra=({"beech","aspen","rowan","hazel","spruce"})[math.abs(p.x*7+p.z*11)%10]
|
||||
local extra=({"beech","aspen","rowan","hazel","spruce","crab_apple","wild_cherry","field_maple"})[math.abs(p.x*7+p.z*11)%13]
|
||||
if extra and D.suitable(extra.."_seedling",h) then return extra.."_seedling" end
|
||||
for _,s in ipairs(choices) do if D.suitable(s.."_seedling",h) then return s.."_seedling" end end
|
||||
end
|
||||
@@ -150,15 +156,24 @@ return function(hearth, api)
|
||||
local current=api.get_node_or_nil(p)
|
||||
if not current or (n and current.name~=n.name) then return false end
|
||||
n=current
|
||||
-- Only fallen logs join the existing flora adoption policy; generic
|
||||
-- litter/fungi and standing log/leaves trees are not newly adopted.
|
||||
if not n or not (D.is_worldgen_flora(n) or n.name=="hearth:deadwood") or not unmarked(p) then return false end
|
||||
local root_species=kind(n) and kind(n):match("^([a-z_]+)_log$")
|
||||
local block_root=(species[root_species] or kind(n)=="log") and hearth.botany~=nil
|
||||
if not n or not (D.is_worldgen_flora(n) or block_root or n.name=="hearth:deadwood") or not unmarked(p) then return false end
|
||||
local below={x=p.x,y=p.y-1,z=p.z}
|
||||
local support=api.get_node_or_nil(below)
|
||||
if not support or not D.supports_ground(n,support) or not unmarked(below) then return false end
|
||||
if block_root and not D.is_ground(support) then return false end
|
||||
local m=api.get_meta(p)
|
||||
if m:get_string("succ_natural")=="1" then return false end
|
||||
m:set_string("succ_natural","1")
|
||||
if block_root then
|
||||
m:set_string("succ_component",p.x..":"..p.y..":"..p.z)
|
||||
m:set_string("succ_worldgen_stage","mature")
|
||||
m:set_string("succ_species",root_species or "generic")
|
||||
end
|
||||
-- Succession adopts a known displayed stage on its first bounded visit;
|
||||
-- bootstrap does not backdate an invented historical birthday.
|
||||
if block_root or (hearth.botany and hearth.botany.kind(n)) then m:set_string("succ_pending_origin","worldgen_established") end
|
||||
return true
|
||||
end
|
||||
local seasons=hearth.seasons
|
||||
@@ -220,7 +235,14 @@ return function(hearth, api)
|
||||
groups={snappy=3,flora=1,flammable=2,hearth_ecosystem=1},drop="",after_place_node=placed})
|
||||
end
|
||||
local names={"hearth:deadwood"}
|
||||
if hearth.botany then
|
||||
names[#names+1]="hearth:log"
|
||||
for _,s in ipairs(D.species_order) do names[#names+1]="hearth:"..s.."_log" end
|
||||
end
|
||||
for k in pairs(flora) do names[#names+1]="hearth:"..k end
|
||||
if hearth.botany then for _,r in ipairs(hearth.botany.list()) do
|
||||
if r.group~="trees" and not flora[r.id] then names[#names+1]=r.node end
|
||||
end end
|
||||
for _,s in ipairs(D.species_order) do for _,stage in ipairs({"seedling","young","mature"}) do names[#names+1]="hearth:"..s.."_"..stage end end
|
||||
if seasons then for _,n in ipairs(seasons.names()) do if D.is_worldgen_flora(n) then names[#names+1]=n end end end
|
||||
api.register_lbm({name="hearth:diversity_natural_flora",label="Mark unmodified species worldgen flora",
|
||||
|
||||
+77
-37
@@ -1,7 +1,8 @@
|
||||
-- Bounded, restart-safe ecology for perennial forage, saplings and seasonal crops.
|
||||
return function(hearth, api, construction)
|
||||
local DAY, MAX_CATCHUP = 86400, 30 * 86400
|
||||
local CROP_STEP, CROP_MATURE_HOURS = 21600, 60
|
||||
local DAY, YEAR = 86400, 365.2425*86400
|
||||
-- Unidentified game grain, not a researched named cereal cultivar.
|
||||
local CROP_STEP, CROP_MATURE_HOURS, CROP_SAMPLES = 21600, 120*24, 366
|
||||
local function finite(value)
|
||||
value = tonumber(value)
|
||||
if not value or value ~= value or value == math.huge or value == -math.huge then return nil end
|
||||
@@ -28,25 +29,26 @@ return function(hearth, api, construction)
|
||||
planted, now = finite(planted), finite(now)
|
||||
if not planted or not now or planted < 0 or now < 0 then return 0 end
|
||||
planted, now = math.floor(planted), math.floor(now)
|
||||
now = math.min(math.floor(now), planted + MAX_CATCHUP)
|
||||
if planted <= 0 or now <= planted then return 0 end
|
||||
local progress, cursor = 0, planted
|
||||
while cursor < now and progress < CROP_MATURE_HOURS do
|
||||
local finish = math.min(now, cursor + CROP_STEP)
|
||||
local sample = cursor + math.floor((finish - cursor) / 2)
|
||||
-- Bounded midpoint quadrature covers the ENTIRE calendar interval. Long
|
||||
-- gaps coarsen samples, never silently discard all age after day thirty.
|
||||
local duration=now-planted
|
||||
local samples=math.min(CROP_SAMPLES,math.max(1,math.ceil(duration/DAY)))
|
||||
local progress=0
|
||||
for i=1,samples do
|
||||
local sample=planted+math.floor(duration*(i-.5)/samples)
|
||||
local climate = hearth.climate_at and hearth.climate_at(sample)
|
||||
local temperature = finite(climate and climate.temperature) or 12
|
||||
local rate = temperature < 3 and 0 or (temperature < 10 and 0.5 or 1)
|
||||
progress = progress + (finish - cursor) / 3600 * rate
|
||||
cursor = finish
|
||||
progress = progress + duration/samples/3600*rate
|
||||
end
|
||||
return math.min(CROP_MATURE_HOURS, progress)
|
||||
return math.min(CROP_MATURE_HOURS,progress,duration/3600)
|
||||
end
|
||||
local function crop_stage(planted, now)
|
||||
local growth = climate_progress(planted, now)
|
||||
if growth >= CROP_MATURE_HOURS then return 4 end
|
||||
if growth >= 36 then return 3 end
|
||||
if growth >= 12 then return 2 end
|
||||
if growth >= 72*24 then return 3 end
|
||||
if growth >= 24*24 then return 2 end
|
||||
return 1
|
||||
end
|
||||
local function update_crop(pos)
|
||||
@@ -54,8 +56,14 @@ return function(hearth, api, construction)
|
||||
if not current or not current.name:match("^hearth:crop_[1234]$") then return false end
|
||||
local meta, now = api.get_meta(pos), safe_now()
|
||||
local planted = tonumber(meta:get_string("planted_at")) or 0
|
||||
if planted <= 0 or planted > now then meta:set_string("planted_at", tostring(now)); planted = now end
|
||||
if planted <= 0 or planted > now then return true end
|
||||
local observed=finite(meta:get_string("crop_last_observed_at")) or planted
|
||||
if observed>now then return true end
|
||||
meta:set_string("crop_last_observed_at",tostring(now))
|
||||
local stage = crop_stage(planted, now)
|
||||
-- Quadrature refinement is approximate, so an established stage never
|
||||
-- regresses when a different sample partition straddles a cold front.
|
||||
stage=math.max(stage,tonumber(current.name:match("(%d)$")))
|
||||
local target = "hearth:crop_" .. stage
|
||||
if current.name ~= target then api.swap_node(pos, {name = target, param2 = current.param2}) end
|
||||
return stage < 4
|
||||
@@ -98,28 +106,37 @@ return function(hearth, api, construction)
|
||||
api.get_meta(pos):set_string("player_placed", "1")
|
||||
end
|
||||
local function harvest_berries(pos, player)
|
||||
if not construction.allowed(pos, player, "use") then return end
|
||||
if not construction.allowed(pos, player, "use") then return false end
|
||||
local n = node(pos)
|
||||
if not n then return end
|
||||
if not n or (n.name~="hearth:berry_bush" and n.name~="hearth:berry_bush_bare") then return false end
|
||||
if n.name == "hearth:berry_bush_bare" then
|
||||
hearth.notify(player, "The berry bush is regrowing; return after one real day.")
|
||||
return
|
||||
hearth.notify(player, "The berry bush has spent this crop; check a future Summer after its annual rest.")
|
||||
return false
|
||||
end
|
||||
local meta,now=api.get_meta(pos),safe_now()
|
||||
local previous=meta:get_string("ready_at")
|
||||
local due=previous=="" and 0 or finite(previous)
|
||||
if not due or due<0 or due>now or now>9007199254740991-YEAR then return false end
|
||||
local inv = player:get_inventory()
|
||||
local output = ItemStack("hearth:berries 3")
|
||||
if not inv or not inv:room_for_item("main", output) then
|
||||
hearth.notify(player, "Make inventory space before picking these berries.")
|
||||
return
|
||||
return false
|
||||
end
|
||||
local requested = output:get_count()
|
||||
-- Shared durable reservation before inventory callbacks, including
|
||||
-- reentrant picking. A rejected transfer restores exactly the old field.
|
||||
local ready = now + YEAR
|
||||
meta:set_string("ready_at",tostring(ready))
|
||||
local leftover = inv:add_item("main", output)
|
||||
if not leftover or leftover:get_count() >= requested then return end
|
||||
api.set_node(pos, {name = "hearth:berry_bush_bare", param2 = n.param2})
|
||||
local ready = safe_now() + DAY
|
||||
api.get_meta(pos):set_string("ready_at", tostring(ready))
|
||||
api.get_meta(pos):set_string("infotext", "Berry bush: regrowing (one real day)")
|
||||
local count=leftover and finite(leftover:get_count())
|
||||
if not count or count<0 or count>requested then return false end -- uncertain delivery stays locked
|
||||
if count==requested then meta:set_string("ready_at",previous); return false end
|
||||
api.swap_node(pos, {name = "hearth:berry_bush_bare", param2 = n.param2})
|
||||
meta:set_string("infotext", "Berry bush: annual rest; next eligible Summer")
|
||||
api.get_node_timer(pos):start(DAY)
|
||||
if hearth.invalidate_environment then hearth.invalidate_environment(pos) end
|
||||
return true
|
||||
end
|
||||
local function harvest_seasonal(pos, player)
|
||||
if not construction.allowed(pos, player, "use") then return false end
|
||||
@@ -129,25 +146,30 @@ return function(hearth, api, construction)
|
||||
local item = ({spring_greens_patch="hearth:spring_greens", autumn_mushrooms_patch="hearth:autumn_mushrooms"})[kind]
|
||||
if not item then return false end
|
||||
local now, meta = safe_now(), api.get_meta(pos)
|
||||
local ready = finite(meta:get_string("succ_harvest_ready_at")) or 0
|
||||
if not seasons.available(kind, now) or ready > now then
|
||||
local previous=meta:get_string("succ_harvest_ready_at")
|
||||
local ready = previous=="" and 0 or finite(previous)
|
||||
if not ready or ready<0 or not seasons.available(kind, now) or ready > now then
|
||||
hearth.notify(player, "This patch rests outside its season or until one real day after picking.")
|
||||
return false
|
||||
end
|
||||
local inv, output = player:get_inventory(), ItemStack(item)
|
||||
if not inv or not inv:room_for_item("main", output) then return false end
|
||||
-- One item only: no partial multi-item harvest can lose or duplicate food.
|
||||
local leftover = inv:add_item("main", output)
|
||||
if not leftover or leftover:get_count() ~= 0 then return false end
|
||||
meta:set_string("succ_harvest_ready_at", tostring(now + DAY))
|
||||
local leftover = inv:add_item("main", output)
|
||||
local count=leftover and finite(leftover:get_count())
|
||||
if count==1 then meta:set_string("succ_harvest_ready_at",previous); return false end
|
||||
if count~=0 then return false end
|
||||
return true
|
||||
end
|
||||
local function refresh_bush(pos)
|
||||
local n = node(pos)
|
||||
if not n or n.name ~= "hearth:berry_bush_bare" then return false end
|
||||
local meta, now = api.get_meta(pos), safe_now()
|
||||
local ready = tonumber(meta:get_string("ready_at")) or 0
|
||||
if ready <= now then
|
||||
local ready = finite(meta:get_string("ready_at"))
|
||||
if not ready or ready<0 then return false end
|
||||
local climate=hearth.climate_at and hearth.climate_at(now)
|
||||
if ready <= now and climate and climate.season=="Summer" then
|
||||
api.swap_node(pos, {name = "hearth:berry_bush", param2 = n.param2})
|
||||
meta:set_string("ready_at", "0"); meta:set_string("infotext", "")
|
||||
api.get_node_timer(pos):stop()
|
||||
@@ -158,8 +180,7 @@ return function(hearth, api, construction)
|
||||
local grow_sapling
|
||||
local function reconcile_sapling(pos)
|
||||
if grow_sapling(pos) then
|
||||
local planted = tonumber(api.get_meta(pos):get_string("planted_at")) or safe_now()
|
||||
api.get_node_timer(pos):start(math.max(60, math.min(DAY, planted + 3 * DAY - safe_now())))
|
||||
api.get_node_timer(pos):start(DAY)
|
||||
end
|
||||
end
|
||||
local function reconcile_bush(pos)
|
||||
@@ -187,17 +208,36 @@ return function(hearth, api, construction)
|
||||
if not n or n.name ~= "hearth:sapling" then return false end
|
||||
local meta, now = api.get_meta(pos), safe_now()
|
||||
local planted = tonumber(meta:get_string("planted_at")) or 0
|
||||
if planted <= 0 or planted > now then meta:set_string("planted_at", tostring(now)); planted = now end
|
||||
if planted <= 0 or planted > now then return true end
|
||||
local previous=finite(meta:get_string("succ_last_observed_at")) or planted
|
||||
if previous>now then return true end
|
||||
meta:set_string("succ_birth_at",tostring(planted))
|
||||
meta:set_string("succ_last_observed_at",tostring(now))
|
||||
meta:set_string("succ_lifecycle_version","1")
|
||||
meta:set_string("succ_lifecycle_origin","player_planted")
|
||||
meta:set_string("succ_lifecycle_profile","generic_tree")
|
||||
meta:set_string("succ_lifecycle_policy","generic_1y_young_10y_mature_unknown_lifespan")
|
||||
local owner = meta:get_string("planter")
|
||||
if now - planted < 3 * DAY or not tree_clear(pos, owner) then return true end
|
||||
for y = 0, 3 do api.set_node({x=pos.x,y=pos.y+y,z=pos.z},{name="hearth:log"}) end
|
||||
if now - planted < YEAR or not tree_clear(pos, owner) then return true end
|
||||
local function plant_wood(q,name)
|
||||
api.set_node(q,{name=name}); api.get_meta(q):set_string("player_placed","1")
|
||||
end
|
||||
for y = 0, 3 do plant_wood({x=pos.x,y=pos.y+y,z=pos.z},"hearth:log") end
|
||||
for y = 3, 5 do for x = -2, 2 do for z = -2, 2 do
|
||||
local radius = y == 5 and 1 or 2
|
||||
if math.abs(x) + math.abs(z) <= radius and not (x == 0 and z == 0 and y < 5) then
|
||||
local p = {x=pos.x+x,y=pos.y+y,z=pos.z+z}
|
||||
if api.get_node(p).name == "air" then api.set_node(p, {name="hearth:leaves"}) end
|
||||
if api.get_node(p).name == "air" then plant_wood(p,"hearth:leaves") end
|
||||
end
|
||||
end end end
|
||||
-- These are player trees, not a newly generated natural forest. Mark
|
||||
-- every written cell so later worldgen adoption cannot erase provenance.
|
||||
local root=api.get_meta(pos)
|
||||
root:set_string("planted_at",tostring(planted)); root:set_string("planter",owner)
|
||||
root:set_string("succ_birth_at",tostring(planted)); root:set_string("succ_last_observed_at",tostring(now))
|
||||
root:set_string("succ_lifecycle_version","1"); root:set_string("succ_lifecycle_origin","player_planted")
|
||||
root:set_string("succ_lifecycle_profile","generic_tree")
|
||||
root:set_string("succ_lifecycle_policy","generic_1y_young_10y_mature_unknown_lifespan")
|
||||
return false
|
||||
end
|
||||
local function plant_sapling(stack, player, pointed)
|
||||
@@ -207,7 +247,7 @@ return function(hearth, api, construction)
|
||||
api.set_node(pos, {name = "hearth:sapling"})
|
||||
local meta = api.get_meta(pos)
|
||||
meta:set_string("planted_at", tostring(safe_now())); meta:set_string("planter", player:get_player_name())
|
||||
api.get_node_timer(pos):start(3 * DAY)
|
||||
api.get_node_timer(pos):start(DAY)
|
||||
stack:take_item(1)
|
||||
return stack
|
||||
end
|
||||
@@ -226,5 +266,5 @@ return function(hearth, api, construction)
|
||||
refresh_bush = refresh_bush, reconcile_bush = reconcile_bush,
|
||||
reconcile_crop = reconcile_crop, plant_sapling = plant_sapling,
|
||||
grow_sapling = grow_sapling, reconcile_sapling = reconcile_sapling,
|
||||
MAX_CATCHUP = MAX_CATCHUP, CROP_MATURE_HOURS = CROP_MATURE_HOURS}
|
||||
CROP_SAMPLE_LIMIT = CROP_SAMPLES, CROP_MATURE_DAYS = 120, CROP_MATURE_HOURS = CROP_MATURE_HOURS}
|
||||
end
|
||||
|
||||
Executable
+115
@@ -0,0 +1,115 @@
|
||||
-- Read-only catalogue factory. Biology comes from lifecycle, never game ticks.
|
||||
return function(hearth, api)
|
||||
local F={VERSION=1,SECONDS_PER_DAY=86400}
|
||||
local function copy(t)
|
||||
if type(t)~="table" then return t end
|
||||
local r={}; for k,v in pairs(t) do r[k]=copy(v) end; return r
|
||||
end
|
||||
local function set(s) local r={}; for id in s:gmatch("%S+") do r[id]=true end; return r end
|
||||
local lifecycle=hearth.lifecycle
|
||||
if not lifecycle and api.get_modpath then lifecycle=dofile(api.get_modpath("hearth").."/lifecycle.lua") end
|
||||
-- Bind-pose bounds measured from faunaBounds(), unitsPerNode=10. Runtime
|
||||
-- collision uses a conservative yaw-independent envelope plus animation margin.
|
||||
-- id, Latin identity (false for legacy deer), medium, food, activity, cap,
|
||||
-- speed (presentation policy, nodes/s), width radius, height, rear, front.
|
||||
local rows={
|
||||
{"hare","Lepus europaeus","land","browse","night",6,1.3,.1375,.814,.3415,.37004},
|
||||
{"deer",false,"land","browse","crepuscular",3,1,.31625,1.5947,.683,.65081},
|
||||
{"fox","Vulpes vulpes","land","predator fruit","night",3,1.2,.15,.6856,.8624,.5028},
|
||||
{"rabbit","Oryctolagus cuniculus","land","browse","crepuscular",6,1,.154,.5509,.25954,.31371},
|
||||
{"red_squirrel","Sciurus vulgaris","land","seed fruit","day",4,.9,.075,.5924,.46744,.24266},
|
||||
{"bank_vole","Myodes glareolus","land","browse seed fruit","any",6,.55,.07,.196,.17039,.17507},
|
||||
{"wood_mouse","Apodemus sylvaticus","land","seed fruit","night",6,.6,.06,.2386,.31045,.162505},
|
||||
{"hedgehog","Erinaceus europaeus","land","predator","night",3,.35,.11,.25,.21173,.23755},
|
||||
{"badger","Meles meles","land","predator fruit","night",2,.65,.19,.4924,.45078,.475885},
|
||||
{"roe_deer","Capreolus capreolus","land","browse","crepuscular",3,1,.22375,1.2285,.49176,.48443},
|
||||
{"stoat","Mustela erminea","land","predator","any",2,1.1,.06,.2462,.4514625,.269535},
|
||||
{"weasel","Mustela nivalis","land","predator","any",2,.85,.0425,.1808,.26846,.19111},
|
||||
{"otter","Lutra lutra","amphibian","predator","night",2,.9,.125,.3884,.70711,.39778},
|
||||
{"robin","Erithacus rubecula","fly","predator fruit seed","day",4,.8,.1646,.3721,.21895,.173875},
|
||||
{"blackbird","Turdus merula","fly","predator fruit","day",4,.8,.1844,.4175,.30653,.2014},
|
||||
{"blue_tit","Cyanistes caeruleus","fly","seed fruit","day",4,.9,.125,.3167,.17516,.13705},
|
||||
{"barn_owl","Tyto alba","fly","predator","night",2,1.2,.2834,.6434,.2736875,.275},
|
||||
{"mallard","Anas platyrhynchos","amphibian","seed browse fruit","day",4,.65,.2735,.4704,.4379,.2555},
|
||||
{"common_frog","Rana temporaria","amphibian","predator","any",4,.3,.099,.1475,.15026,.22272},
|
||||
{"smooth_newt","Lissotriton vulgaris","amphibian","predator","night",4,.16,.04,.1014,.3423125,.162945},
|
||||
{"brown_trout","Salmo trutta","swim","predator","any",4,.65,.1045,.3045,.3552,.24},
|
||||
{"bumblebee","Bombus terrestris","fly","nectar","day",6,.65,.144,.1225,.065,.1064},
|
||||
{"earthworm","Lumbricus terrestris","invertebrate","litter","night",8,20/3600,.02,.04,.191266666666667,.191266666666667},
|
||||
}
|
||||
local rest_policy={
|
||||
day={kind="solar_day",sleep_below=-6,winddown_below=6},
|
||||
night={kind="solar_night",sleep_above=6,winddown_above=-6},
|
||||
crepuscular={kind="twilight",active_between={-8,8},rest_hours={11,15}},
|
||||
any={kind="flexible",rest_hours={12,14},winddown_hours={11.5,14.5}},
|
||||
}
|
||||
local winter_dormant=set("hedgehog common_frog smooth_newt")
|
||||
local prey={fox="hare rabbit bank_vole wood_mouse red_squirrel earthworm common_frog",
|
||||
stoat="rabbit bank_vole wood_mouse",weasel="bank_vole wood_mouse robin blue_tit",
|
||||
barn_owl="bank_vole wood_mouse",badger="earthworm bank_vole wood_mouse hedgehog",
|
||||
hedgehog="earthworm",otter="brown_trout common_frog smooth_newt",
|
||||
robin="earthworm",blackbird="earthworm",common_frog="earthworm",smooth_newt="earthworm",
|
||||
-- No imaginary mayfly/larva population and no adult-trout cannibalism.
|
||||
brown_trout=""}
|
||||
local index,order={},{}
|
||||
local clips={idle={0,19},locomotion={20,39},feed={40,59},flight_swim={60,79},rest={80,99}}
|
||||
for _,v in ipairs(rows) do
|
||||
local id=v[1]; local profile=lifecycle and lifecycle.animal(id) or false
|
||||
local url="https://en.wikipedia.org/wiki/"..(v[2] and v[2]:gsub(" ","_") or "Deer")
|
||||
local r={id=id,kind="animal",entity="hearth:"..id,common=id:gsub("_"," "),latin=v[2],
|
||||
medium=v[3],foods=set(v[4]),activity=v[5],cap=v[6],speed=v[7],
|
||||
bounds={min={-v[8],0,-v[10]},max={v[8],v[9],v[11]}},
|
||||
mesh="hearth_fauna_"..id..".b3d",texture="hearth_fauna_"..id..".png",
|
||||
clips=copy(clips),fps=24,frames=100,eye_height=math.max(.15,v[9]*.8),
|
||||
prey=set(prey[id] or ""),profile=profile,source_url=url,wikipedia_url=url,
|
||||
sleep=copy(rest_policy[v[5]] or rest_policy.crepuscular),
|
||||
winter_dormant=winter_dormant[id]==true,
|
||||
sources={url},confidence=id=="deer" and "unverified_identity" or "species_account",
|
||||
-- Numeric feeding cadence/reserve is a coarse energy accounting policy,
|
||||
-- NOT a claimed species metabolic/starvation measurement.
|
||||
feed_seconds=(id=="bumblebee" and 300 or (prey[id] and 7200 or 3600)),
|
||||
reserve_seconds=2*86400,starvation_days=false}
|
||||
if profile and profile.source_url then r.sources[#r.sources+1]=profile.source_url end
|
||||
if id=="rabbit" then
|
||||
r.starvation_days=8; r.starvation_source=url.."#Diet"
|
||||
end
|
||||
index[id]=r; order[#order+1]=id
|
||||
end
|
||||
function F.kind(value)
|
||||
local n=type(value)=="table" and value.name or value
|
||||
if type(n)~="string" or #n>80 then return nil end
|
||||
n=n:gsub("^hearth:",""); return index[n] and n or nil
|
||||
end
|
||||
function F.get(value) return copy(index[F.kind(value)]) end
|
||||
function F.list() local out={}; for _,id in ipairs(order) do out[#out+1]=copy(index[id]) end; return out end
|
||||
F.catalog=F.list
|
||||
function F.source(value) local r=index[F.kind(value)]; return r and copy(r.sources) end
|
||||
function F.phase(id,born,now)
|
||||
local r=index[F.kind(id)]
|
||||
return r and r.profile and lifecycle and lifecycle.phase(r.profile,born,now) or nil
|
||||
end
|
||||
function F.breeding_window(id,month)
|
||||
local r=index[F.kind(id)]
|
||||
return r and r.profile and lifecycle and lifecycle.breeding_window(r.profile,month) or nil
|
||||
end
|
||||
function F.can_breed(id,born,now,sex,current_season)
|
||||
local r=index[F.kind(id)]; local p=r and r.profile
|
||||
if not p or p.reproduction_enabled~=true then return false,"incomplete_profile" end
|
||||
if sex~="female" and sex~="male" then return false,"sex" end
|
||||
local development=p.reproduction=="egg" and "incubation_days" or "gestation_days"
|
||||
for _,field in ipairs({"maturity_days",development,"juvenile_days","birth_count"}) do
|
||||
local t=p[field]
|
||||
if type(t)~="table" or t.confidence~="source_reported" or not t.source_url then return false,"incomplete_profile" end
|
||||
end
|
||||
local phase=F.phase(id,born,now)
|
||||
if not phase then return false,"age" end
|
||||
if current_season==nil and hearth.climate_at then
|
||||
local c=hearth.climate_at(now); current_season=c and c.season
|
||||
end
|
||||
-- Climate's season already accounts for the configured hemisphere. Do
|
||||
-- not intersect it with northern UK months (which breaks southern spring).
|
||||
return phase.phase=="mature" and type(p.breeding_seasons)=="table"
|
||||
and p.breeding_seasons[current_season]==true or false
|
||||
end
|
||||
return F
|
||||
end
|
||||
+269
-25
@@ -1,5 +1,10 @@
|
||||
local C = core
|
||||
local esc = C.formspec_escape
|
||||
local function tooltip(name,text)
|
||||
-- Luanti 5.18 requires both tooltip colors when the optional color slots are
|
||||
-- parsed. Supplying the Mocha pair also keeps high-density creator menus legible.
|
||||
return "tooltip[" .. esc(name) .. ";" .. esc(text) .. ";#181825;#cdd6f4]"
|
||||
end
|
||||
-- eom.dev Mocha anchors: shared by every journal surface, not terrain tints.
|
||||
local ink = {base="#1e1e2e", mantle="#181825", raised="#292940", border="#3c3d56",
|
||||
text="#cdd6f4", muted="#a6adc8", mauve="#cba6f7", teal="#94e2d5", yellow="#f9e2af", danger="#f38ba8"}
|
||||
@@ -50,11 +55,11 @@ local pages = {
|
||||
{"Your homestead", "MAKE A PLACE YOURS", "A community without compulsory chores.",
|
||||
"Use /claim at your homestead to protect a 25 x 25 area, extending 24 blocks up/down. Use /claim show for temporary boundary markers. Share with /claim share <player> resident (build and household use) or /claim share <player> storekeeper (storage only). /claim unshare <player> revokes access; /claim list shows members; /claim remove releases the claim and grants.\n\nPlace homestead storage inside a claim. Its inventory stays private to the owner, residents and storekeepers; non-empty storage cannot be removed. Workstations and construction require resident permissions.\n\nUse /sethome in your own safe claim; /home rechecks that position before moving you and safely falls back to the world arrival if the home is blocked or hazardous. Your inventory, food, water and wetness persist through death.\n\nLogged-out player needs pause. Hearth processes and fuel use real elapsed time; completed output remains stored until claimed. Food does not spoil in this slice. This remains a prototype, not a hardened public service."},
|
||||
{"Living ecosystems", "NOTHING GROWS ALONE", "Watch a meadow change; there is no quest to complete.",
|
||||
"An original succession model inspired by Dynamic Life connects light, moisture, plants and animals. The botanical catalogue has 80 named species: eight categories of ten each. Use the Botanical guide button or /botany [search] for common and scientific names, habitat, seasons, toxicity labels and roles. These are game-only notes, not a real-world forage guide.\n\nBirch pioneers open gaps; oak favors meadow/forest soils. Evergreen pine and spruce keep winter shade; deciduous oak, birch, willow, alder, beech, aspen, rowan and hazel thin in Winter. Willow favors wet alluvium/peat. Nitrogen-fixer policy can add at most two nutrient units per support across replacements/restarts while reserves are low. The legacy alder terrain profile alone is not a separate fertilizer process. Litter, fungi, deadwood and dung recycle finite nutrients. Fallen logs are ordinary sideways cubes: an axe yields one log, not hand-gathered sticks.\n\nSpring/Summer clover and flowers support pollinators; spring bluebells and autumn heather have seasonal windows. Wet sedge/bulrush, shade moss/ferns, rock lichen and nutrient-needing nettle have distinct niches. The survey counts supporting plants, not insects. Hares and deer graze natural cover; foxes pursue hares. Use /ecosystem for actual ground, light, moisture, canopy, nutrient reserve and active wildlife totals. Moisture is a bounded habitat estimate, not fully simulated hydrology, erosion or flooding.\n\nRoles are conditional policies or descriptors, not effects guaranteed for every species: parasitism and allelopathy cause no plant damage, and a resin tag is not an extraction recipe. Wild succession advances only around connected players in loaded areas. It pauses across unloading and restart, with no offline catch-up. Claims, placed plants, crops and construction are excluded from autonomous changes. Stages take active sessions, not a daily login. Crops and planted saplings keep separate growth rules."},
|
||||
"An original succession model inspired by Dynamic Life connects light, moisture, plants and animals. The botanical catalogue has 100 named species in eight uneven ecological groups, not eight groups of ten. Use /botany [search] for names, habitat, seasons, toxicity labels and roles; /fauna lists 23 animal identities with sources and lifecycle status. These are game-only notes, not a real-world forage guide.\n\nBirch pioneers open gaps; oak favors meadow/forest soils. Evergreen pine and spruce keep winter shade; deciduous trees including crab apple, wild cherry and field maple thin in Winter. Willow favors wet alluvium/peat. Nitrogen-fixer policy can add at most two nutrient units per support after 90 real calendar days, across replacements/restarts while reserves are low. The legacy alder terrain profile alone is not a separate fertilizer process. Litter, fungi, deadwood and dung recycle finite nutrients. Fallen logs are ordinary sideways cubes: an axe yields one log, not hand-gathered sticks.\n\nSpring/Summer clover and flowers support pollinators; spring bluebells and autumn heather have seasonal windows. Wet sedge/bulrush, shade moss/ferns, rock lichen and nutrient-needing nettle have distinct niches. The plant survey counts supporting plants, not insects; wildlife counts include every active species. Animals use finite browse, nectar, fruit, seeds, litter or modeled prey. Use /ecosystem for observations. Moisture is a bounded habitat estimate, not fully simulated hydrology, erosion or flooding.\n\nRoles are conditional policies or descriptors: parasitism and allelopathy cause no plant damage; a resin tag is not an extraction recipe. Calendar ages include unloaded time. Ecological work advances only in loaded areas with no offline catch-up or missed-event replay. Claims, placed plants, crops and construction are excluded from autonomous changes. Named tree stages use real years to decades, not active sessions; fungi do not accelerate growth. Source-reported fields, policy fallbacks and UNKNOWN are distinct. All incomplete animal profiles disable breeding; initial established wildlife are immigration, not adult births. Unknown age and lifespan endpoints never authorize automatic death."},
|
||||
{"Clock & location", "ONE WORLD. ONE CLOCK.", "A real calendar, not a daily login requirement.",
|
||||
"Local play asks you to choose approximate coordinates. No location service, IP lookup or external request is used. Use /location to change them. Coordinates stay in local world storage.\n\nA community server must set its shared latitude and longitude in configuration. Remote players cannot override them. The world follows the host's real UTC clock.\n\nSun height and day length follow latitude, longitude and date. The engine's fixed sky orbit is approximated; it is not an exact astronomical renderer. The HUD uses UTC and local solar time, not civil timezone labels.\n\nClimate and rain are synthetic. Calendar-season labels use meteorological months; southern latitudes reverse them."},
|
||||
{"Geology & timber", "READ THE REGIONAL MATERIALS", "Ten biomes; shared sky, different ground.",
|
||||
"Temperate meadow and oak woodland favor limestone; birch forest and pine taiga favor granite. Wet forest has peat over slate; floodplain has alluvium over sandstone. Dry scrub has sandstone, alpine meadow has turf/scree over slate, rocky peaks have snow/scree over granite, and riverbank has sand/gravel over sandstone. These are ten terrain communities, not ten independent weather simulations. The climate HUD remains shared; altitude still affects exposure temperature.\n\nQuartz seams occur in granite/slate; copper in basalt/limestone/slate up to Y=32; coal in sandstone/slate/limestone up to Y=70. Deeper beds and basalt intrusions differ from surface rock. Mine with a pick; each seam drops two resource items. Three quartz craft one existing window, without a glass furnace.\n\nOne copper ore + one charcoal craft one copper charge. At a lit hearth it takes 45 real seconds and reserves 30 fuel seconds, yielding one ingot. Three copper ingots across the top row, with two sticks down the center, make one faster, longer-lasting copper pick. Iron remains a separate 60-second smelt and axe route.\n\nOak, birch, pine, willow, alder, beech, aspen, rowan, hazel and spruce logs each burn for 180 seconds, or craft four matching planks. Convert two matching species logs to two canonical logs for log + clay charcoal charges; convert species planks 1:1 to canonical planks for exact-name building recipes. One regional rock crafts four matching bricks; two matching rocks convert to two canonical stones. Put each input in separate crafting slots (shapeless); there is no single-log or single-rock canonical conversion. Fallen logs are sideways full cubes; an axe yields one canonical log, never two hand-gathered sticks. Coal burns 240 seconds; charcoal 90; sticks 45. All fuel is capped at 600 seconds. Coal is geological and nonrenewable, not a renewable fuel loop.\n\nThe broad-relief terrain preset is for fresh worlds only. Existing chunks stay untouched; new biome registrations can change unexplored old-world boundaries. Operators must explicitly choose a new world or accept those seams; never delete map metadata to force a migration."},
|
||||
"Temperate meadow and oak woodland favor limestone; birch forest and pine taiga favor granite. Wet forest has peat over slate; floodplain has alluvium over sandstone. Dry scrub has sandstone, alpine meadow has turf/scree over slate, rocky peaks have snow/scree over granite, and riverbank has sand/gravel over sandstone. These are ten terrain communities, not ten independent weather simulations. The climate HUD remains shared; altitude still affects exposure temperature.\n\nQuartz seams occur in granite/slate; copper in basalt/limestone/slate up to Y=32; coal in sandstone/slate/limestone up to Y=70. Deeper beds and basalt intrusions differ from surface rock. Mine with a pick; each seam drops two resource items. Three quartz craft one existing window, without a glass furnace.\n\nOne copper ore + one charcoal craft one copper charge. At a lit hearth it takes 45 real seconds and reserves 30 fuel seconds, yielding one ingot. Three copper ingots across the top row, with two sticks down the center, make one faster, longer-lasting copper pick. Iron remains a separate 60-second smelt and axe route.\n\nOak, birch, pine, willow, alder, beech, aspen, rowan, hazel, spruce, crab apple, wild cherry and field maple logs each burn for 180 seconds, or craft four matching planks. Convert two matching species logs to two canonical logs for log + clay charcoal charges; convert species planks 1:1 to canonical planks for exact-name building recipes. One regional rock crafts four matching bricks; two matching rocks convert to two canonical stones. Put each input in separate crafting slots (shapeless); there is no single-log or single-rock canonical conversion. Fallen logs are sideways full cubes; an axe yields one canonical log, never two hand-gathered sticks. Coal burns 240 seconds; charcoal 90; sticks 45. All fuel is capped at 600 seconds. Coal is geological and nonrenewable, not a renewable fuel loop.\n\nThe broad-relief terrain preset is for fresh worlds only. Existing chunks stay untouched; new biome registrations can change unexplored old-world boundaries. Operators must explicitly choose a new world or accept those seams; never delete map metadata to force a migration."},
|
||||
}
|
||||
local botanical_groups = {
|
||||
{"trees", "Trees"}, {"shrubs", "Shrubs"}, {"grasses", "Grasses, sedges & rushes"},
|
||||
@@ -68,7 +73,7 @@ end
|
||||
local function botanical_summary()
|
||||
local records, lines = catalogue(), {}
|
||||
if #records == 0 then return "Botanical catalogue unavailable in this runtime. Use /botany when installed." end
|
||||
lines[1] = "BOTANICAL CATALOGUE / " .. #records .. " named species; eight categories of ten in the full catalogue.\nUse /botany [search] for scientific names, habitat windows, seasons and ecological roles."
|
||||
lines[1] = "BOTANICAL CATALOGUE / " .. #records .. " named species; eight ecological groups of uneven sizes.\nUse /botany [search] for scientific names, habitat windows, seasons and ecological roles; /fauna for 23 animal identities and lifecycle evidence."
|
||||
for _, group in ipairs(botanical_groups) do
|
||||
local names = {}
|
||||
for _, r in ipairs(records) do
|
||||
@@ -99,11 +104,12 @@ function hearth.guide(player, page)
|
||||
local notes = p[4] .. (page == 6 and "\n\n" .. botanical_summary() or "")
|
||||
f[#f + 1] = "textarea[3.8,2.65;9.6,5.6;;;" .. esc(notes) .. "]"
|
||||
f[#f + 1] = "button[3.85,8.15;2.3,0.55;inspect_target;Inspect target]"
|
||||
f[#f + 1] = "button[6.3,8.15;2.1,0.55;fauna_index;Fauna guide]"
|
||||
f[#f + 1] = "button[3.85,8.85;2.3,0.65;ecosystem_survey;Ecosystem survey]"
|
||||
.. "tooltip[ecosystem_survey;Read a loaded habitat patch and global active wildlife. No world changes.;" .. ink.mantle .. ";" .. ink.text .. "]"
|
||||
.. tooltip("ecosystem_survey","Read a loaded habitat patch and global active wildlife. No world changes.")
|
||||
f[#f + 1] = "button[6.3,8.85;2.1,0.65;botany_index;Botanical guide]"
|
||||
f[#f + 1] = "button[8.55,8.85;2.1,0.65;recipe_index;Recipe index]"
|
||||
.. "tooltip[recipe_index;Search ingredients, results and real-time processes.;" .. ink.mantle .. ";" .. ink.text .. "]"
|
||||
.. tooltip("recipe_index","Search ingredients, results and real-time processes.")
|
||||
f[#f + 1] = "button_exit[10.8,8.85;2.5,0.65;close;Back to the valley]"
|
||||
C.show_formspec(player:get_player_name(), "hearth:guide", table.concat(f))
|
||||
end
|
||||
@@ -112,6 +118,7 @@ local recipe_outputs = {"hearth:knife", "hearth:axe", "hearth:pick", "hearth:spe
|
||||
"hearth:copper_charge", "hearth:copper_pick", "hearth:log", "hearth:stone",
|
||||
"hearth:oak_planks", "hearth:birch_planks", "hearth:pine_planks", "hearth:willow_planks", "hearth:alder_planks",
|
||||
"hearth:beech_planks", "hearth:aspen_planks", "hearth:rowan_planks", "hearth:hazel_planks", "hearth:spruce_planks",
|
||||
"hearth:crab_apple_planks", "hearth:wild_cherry_planks", "hearth:field_maple_planks",
|
||||
"hearth:granite_bricks", "hearth:basalt_bricks", "hearth:limestone_bricks", "hearth:slate_bricks", "hearth:sandstone_bricks",
|
||||
"hearth:firestarter", "hearth:vessel", "hearth:torch", "hearth:fire", "hearth:wall",
|
||||
"hearth:roof", "hearth:foundation", "hearth:frame", "hearth:stairs", "hearth:door",
|
||||
@@ -127,8 +134,8 @@ local process_notes = {
|
||||
{"Smelting", "One iron ore/charcoal charge + lit hearth: 60 real seconds; 30 fuel seconds reserved at start. Copper uses its separate 45-second charge."},
|
||||
{"Copper smelting", "One hearth:copper_charge (one hearth:copper_ore + one hearth:charcoal) + lit hearth: 45 real seconds; 30 fuel seconds reserved at start; one hearth:copper_ingot output. Requires at least 75 fuel seconds to queue. Collect empty-handed; a full inventory leaves the ingot waiting. Copper pick: three ingots across the top and two central sticks."},
|
||||
{"Regional geology", "Quartz in granite/slate: three hearth:quartz craft one window; the reed/plank route remains. Copper ore in basalt/limestone/slate; coal in sandstone/slate/limestone. Each seam drops two items. One granite, basalt, limestone, slate or sandstone crafts four matching bricks. Convert two matching rocks to two canonical stones in separate crafting slots (shapeless); no single-rock canonical conversion."},
|
||||
{"Species timber", "One oak, birch, pine, willow, alder, beech, aspen, rowan, hazel or spruce log crafts four matching planks. Convert two matching species logs in separate crafting slots (shapeless) to two canonical hearth:log; no single-log canonical conversion. Species planks convert 1:1 to canonical hearth:planks. Charcoal charges accept only canonical log + clay: convert species logs in matching pairs first, not four planks back into logs."},
|
||||
{"Finite nitrogen", "Nitrogen-fixer policy, including clover and alder in the botanical runtime, can add at most two nutrient units per support across replacements and restarts while the reserve is low. The legacy alder terrain profile alone is not a fertilizer process. Nutrient reserve is bounded at eight; litter, deadwood, fungi and dung recycle finite material."},
|
||||
{"Species timber", "One oak, birch, pine, willow, alder, beech, aspen, rowan, hazel, spruce, crab_apple, wild_cherry or field_maple log crafts four matching planks. Convert two matching species logs in separate crafting slots (shapeless) to two canonical hearth:log; no single-log canonical conversion. Species planks convert 1:1 to canonical hearth:planks. Charcoal charges accept only canonical log + clay: convert species logs in matching pairs first, not four planks back into logs."},
|
||||
{"Finite nitrogen", "After 90 real calendar days, nitrogen-fixer policy, including clover and alder in the botanical runtime, can add at most two nutrient units per support across replacements and restarts while the reserve is low and conditions are warm. This cadence is game policy, not a measured species rate. The legacy alder terrain profile alone is not a fertilizer process. Nutrient reserve is bounded at eight; litter, deadwood, fungi and dung recycle finite material."},
|
||||
{"Fallen logs", "Ordinary full-cube fallen logs lie sideways. Use an axe to harvest one canonical hearth:log; hands do not yield sticks. They also support decomposer habitat. Player-placed and claimed material is excluded from autonomous succession."},
|
||||
{"Pottery firing", "One shaped clay-jar charge + lit hearth: 120 real seconds; 30 fuel seconds reserved at start."},
|
||||
{"Food drying", "One cooked-food/reed charge + lit hearth: 300 real seconds; 30 fuel seconds reserved at start."},
|
||||
@@ -136,11 +143,11 @@ local process_notes = {
|
||||
{"Fish preparation", "Roast one raw fish at a lit hearth for 8 real seconds; reserve 20 fuel seconds. Combine cooked fish with reeds into a drying charge, then dry for 300 seconds with 30 fuel seconds reserved. Dried fish is shelf-stable."},
|
||||
{"Spring fish run", "A trap finishing during spring returns two fish; other seasons return one. The catch is determined by its scheduled deadline, even if you are away."},
|
||||
{"Light a hearth", "Fuel first, then use a flint firestarter. Each starter has 20 successful ignitions."},
|
||||
{"Fire fuel", "Each stick adds 45 real seconds; canonical, oak, birch, pine, willow, alder, beech, aspen, rowan, hazel and spruce logs each add 180; charcoal adds 90; coal adds 240 seconds. Stored reserve is capped at 600; a fuel item that would exceed it is not consumed. Planks and buildings are not direct fuel. Coal is geological and nonrenewable; it cannot be crafted or grown."},
|
||||
{"Fire fuel", "Each stick adds 45 real seconds; canonical, oak, birch, pine, willow, alder, beech, aspen, rowan, hazel, spruce, crab_apple, wild_cherry and field_maple logs each add 180; charcoal adds 90; coal adds 240 seconds. Stored reserve is capped at 600; a fuel item that would exceed it is not consumed. Planks and buildings are not direct fuel. Coal is geological and nonrenewable; it cannot be crafted or grown."},
|
||||
{"Charcoal", "Combine one log with river clay, then burn the charge at a lit hearth for 60 seconds with 30 fuel seconds reserved. The resulting charcoal stores 90 seconds; total fuel recovered never exceeds the log's 180 seconds."},
|
||||
{"Bedroll rest", "Craft from three planks and three reeds. Right-click beneath a roof; remain still and safe for five seconds."},
|
||||
{"Crop growth", "Plant grain seed on grass or soil. Growth samples historical local solar climate; cold weather pauses it. Harvest ripe crops for grain, then reserve one kernel to replant."},
|
||||
{"Renewal", "Berry bushes return after one real day. A planted sapling grows after three days when its bounded tree volume is clear. No resource can regrow above map limits or through claims."},
|
||||
{"Renewal", "Berry crops rest one mean real year (365.2425 days), then require their fruit season; no 24-hour refill or missed-crop replay. Human and animal botanical fruit share one readiness deadline. Pollination adds at most one berry to an existing crop once. A planted generic sapling requires one real year and clear bounded tree volume; named trees use species lifecycle endpoints, often years to decades. Unknown timing remains labelled policy or UNKNOWN, never invented biology. No resource grows through claims or map limits."},
|
||||
{"Preservation", "Combine cooked food with reeds into a drying charge, then process it at a lit hearth. Dried rations are shelf-stable; general food spoilage is currently off."},
|
||||
{"Spring fish", "Spring catches can produce two fish per baited trap. Search this index for fish preparation; it is seasonal but not time-critical."},
|
||||
{"Pottery", "Shape six river clay into an unfired jar, then fire it at a lit hearth. Use the fired jar with planks to make a larger pantry."},
|
||||
@@ -207,8 +214,8 @@ function hearth.recipes(player, query, page)
|
||||
.. "box[0.65,2.2;9.3,0.8;" .. ink.mantle .. "]"
|
||||
.. "field[0.7,2.25;9.2,0.7;query;Result or ingredient (plain text);" .. esc(query) .. "]field_close_on_enter[query;false]"
|
||||
.. "button[10.25,2.25;3.1,0.7;search;Search]"
|
||||
.. "tooltip[query;Up to 48 characters. Search matches literal text, not patterns.;" .. ink.mantle .. ";" .. ink.text .. "]"
|
||||
.. "tooltip[search;Search crafting recipes and real-time hearth processes.;" .. ink.mantle .. ";" .. ink.text .. "]"
|
||||
.. tooltip("query","Up to 48 characters. Search matches literal text, not patterns.")
|
||||
.. tooltip("search","Search crafting recipes and real-time hearth processes.")
|
||||
.. label(0.7,3.3,"Page " .. page .. " / " .. pages_count .. " / " .. #all .. " matches",ink.muted)
|
||||
.. "textarea[0.65,3.7;12.7,4.6;;;" .. esc(table.concat(lines, "\n\n")) .. "]"
|
||||
.. (page > 1 and "button[0.7,8.85;2.8,0.65;previous;Previous]" or "")
|
||||
@@ -284,7 +291,7 @@ function hearth.botanical_guide(player, query, page)
|
||||
session.botany_query, session.botany_page = query, page
|
||||
C.show_formspec(name,"hearth:botany",journal(14,10)
|
||||
.. label(0.65,0.55,"eom.dev / BOTANICAL FIELD GUIDE",ink.mauve)
|
||||
.. label(0.65,1.15,#records .. " named species / eight categories of ten in the full catalogue",ink.yellow)
|
||||
.. label(0.65,1.15,#records .. " named species / eight ecological groups of uneven sizes",ink.yellow)
|
||||
.. divider(0.65,1.65,12.7)
|
||||
.. "field[0.7,1.95;9.2,0.7;query;Species, scientific name, category, habitat or role;" .. esc(query) .. "]field_close_on_enter[query;false]"
|
||||
.. "button[10.25,1.95;3.1,0.7;search;Search]"
|
||||
@@ -292,13 +299,201 @@ function hearth.botanical_guide(player, query, page)
|
||||
.. "textarea[0.65,3.3;12.7,4.35;;;" .. esc(table.concat(lines,"\n\n")) .. "]"
|
||||
.. "textarea[0.65,7.7;12.7,0.55;;;" .. esc("Game-only notes, NOT a real-world forage guide. Unflagged does not mean safe to eat. Roles are conditional, not universal effects.") .. "]"
|
||||
.. (#choices > 0 and "dropdown[0.7,8.3;5,0.45;species_choice;" .. table.concat(choices,",") .. ";1;true]"
|
||||
.. "tooltip[species_choice;Species article]button[6,8.3;3.3,0.45;species_article;Species article]" or "")
|
||||
.. tooltip("species_choice","Species article") .. "button[6,8.3;3.3,0.45;species_article;Species article]" or "")
|
||||
.. "button[10.05,8.3;3.3,0.45;fauna_index;Fauna guide]"
|
||||
.. (page > 1 and "button[0.7,8.85;2.2,0.65;previous;Previous]" or "")
|
||||
.. (page < count and "button[3.05,8.85;2.2,0.65;next;Next]" or "")
|
||||
.. "button[6,8.85;3.3,0.65;ecosystem_survey;Ecosystem survey]"
|
||||
.. "button[10.05,8.85;3.3,0.65;field_notes;Back to field notes]")
|
||||
end
|
||||
|
||||
-- Observational catalogue, independent of the plant-only article reader.
|
||||
local function fauna_observations(player, discovered_id)
|
||||
local F=hearth.fauna
|
||||
if not F or type(F.list)~="function" or type(player.get_meta)~="function" then return nil end
|
||||
local allowed={}; for _,record in ipairs(F.list()) do allowed[record.id]=true end
|
||||
local ok,meta=pcall(function() return player:get_meta() end)
|
||||
if not ok or not meta or type(meta.get_string)~="function" or type(meta.set_string)~="function" then return nil end
|
||||
local key="hearth:fauna_observations_v1"
|
||||
local read_ok,raw=pcall(meta.get_string,meta,key)
|
||||
if not read_ok or type(raw)~="string" or #raw>512 then return nil end
|
||||
local seen,ordered={},{}
|
||||
if raw~="" then
|
||||
for id in raw:gmatch("[^,]+") do
|
||||
if not allowed[id] or seen[id] then return nil end
|
||||
seen[id]=true; ordered[#ordered+1]=id
|
||||
end
|
||||
table.sort(ordered)
|
||||
if table.concat(ordered,",")~=raw then return nil end
|
||||
end
|
||||
local first=discovered_id and not seen[discovered_id] or false
|
||||
if first then
|
||||
seen[discovered_id]=true; ordered[#ordered+1]=discovered_id; table.sort(ordered)
|
||||
local encoded=table.concat(ordered,",")
|
||||
local write_ok=pcall(meta.set_string,meta,key,encoded)
|
||||
local verify_ok,verified=pcall(meta.get_string,meta,key)
|
||||
if not write_ok or not verify_ok or verified~=encoded then return nil end
|
||||
end
|
||||
return seen,first
|
||||
end
|
||||
|
||||
function hearth.inspect_fauna(player,id,snapshot)
|
||||
local ok,name=pcall(function() return player:get_player_name() end)
|
||||
if not ok or type(name)~="string" or #name<1 or #name>64 or not hearth.sessions[name]
|
||||
or type(C.get_player_by_name)~="function" then return false end
|
||||
local online_ok,online=pcall(C.get_player_by_name,name)
|
||||
if not online_ok or online~=player then return false end
|
||||
local F=hearth.fauna
|
||||
if type(id)~="string" or #id>80 or not F or type(F.get)~="function" then return false end
|
||||
local loaded,record=pcall(F.get,id)
|
||||
if not loaded or type(record)~="table" or record.id~=id then return false end
|
||||
local view
|
||||
if snapshot~=nil then
|
||||
if type(snapshot)~="table" or getmetatable(snapshot)~=nil then return false end
|
||||
view={}
|
||||
for _,key in ipairs({"born_at","origin","foodreserve","resident","observation"}) do
|
||||
local value=snapshot[key]
|
||||
if key=="born_at" or key=="foodreserve" then
|
||||
if type(value)~="number" or value~=value or math.abs(value)==math.huge then return false end
|
||||
elseif value~=nil and type(value)~="string" and type(value)~="boolean" then return false end
|
||||
view[key]=value
|
||||
end
|
||||
if view.born_at<0 or view.born_at%1~=0 or view.foodreserve<0 or view.foodreserve>1
|
||||
or type(view.resident)~="boolean"
|
||||
or (view.origin~="established" and view.origin~="born")
|
||||
or (view.observation~="rest" and view.observation~="awake") then return false end
|
||||
end
|
||||
local sightings,first_sighting=fauna_observations(player,view and id or nil)
|
||||
local function safe(value,limit)
|
||||
if type(value)~="string" then return "unknown" end
|
||||
return value:gsub("[%c]"," "):sub(1,limit or 240)
|
||||
end
|
||||
local function safe_words(set)
|
||||
local out={}
|
||||
if type(set)=="table" then
|
||||
for key,enabled in pairs(set) do
|
||||
if enabled and type(key)=="string" and #out<32 then out[#out+1]=safe(key,40):gsub("_"," ") end
|
||||
end
|
||||
end
|
||||
table.sort(out)
|
||||
return #out>0 and table.concat(out,", ") or "not specified"
|
||||
end
|
||||
local profile=record.profile or {}
|
||||
local lines={}
|
||||
local now_ok,now=pcall(os.time)
|
||||
local phase
|
||||
if view and now_ok and type(F.phase)=="function" then
|
||||
local phase_ok,result=pcall(F.phase,id,view.born_at,now)
|
||||
if phase_ok and type(result)=="table" then phase=result end
|
||||
end
|
||||
if phase then
|
||||
lines[#lines+1]=string.format("Lifecycle: %s / approx %.2f model years; %.1f real calendar days",safe(phase.phase,40),phase.age_days/365.2425,phase.age_days)
|
||||
else
|
||||
lines[#lines+1]="Lifecycle age: unavailable for this profile or no individual selected."
|
||||
end
|
||||
if view then
|
||||
local reserve=math.max(0,math.min(1,view.foodreserve))
|
||||
lines[#lines+1]=string.format("Coarse modeled food reserve (policy, not biology): %.0f%%",reserve*100)
|
||||
lines[#lines+1]="Current animation observation: "..(view.observation=="rest" and "resting" or "awake / active")
|
||||
lines[#lines+1]="Residency: "..(view.resident and "persistent registered resident" or "persistent mapblock resident; registry ID unavailable")
|
||||
.." ("..safe(view.origin,24).." origin); individual position is not shown."
|
||||
lines[#lines+1]=sightings and (first_sighting and "Field journal: first recorded encounter with this species."
|
||||
or "Field journal: this species is already recorded.") or "Field journal: unavailable; encounter was not recorded."
|
||||
else
|
||||
lines[#lines+1]="Individual observation: none; this is a species profile, not a local census."
|
||||
lines[#lines+1]="Field journal: browsing this catalogue entry does not count as a wildlife encounter."
|
||||
end
|
||||
lines[#lines+1]="Modeled diet: "..safe_words(record.foods)
|
||||
lines[#lines+1]="Activity / sleep policy: "..safe(record.activity,32).." / "..safe(record.sleep and record.sleep.kind,40)
|
||||
lines[#lines+1]="Winter rest policy: "..(record.winter_dormant and "coarse dormancy; resident remains persistent" or "no special winter dormancy modeled")
|
||||
lines[#lines+1]="Modeled behavior: "..safe(profile.movement or "movement details unavailable",220)
|
||||
lines[#lines+1]="Modeled prey: "..safe_words(record.prey)
|
||||
if profile.reproduction_enabled==true then
|
||||
lines[#lines+1]="Reproduction: enabled only within the coarse model; mature compatible pairs, season, food and safe habitat are still required."
|
||||
else
|
||||
local evidence=profile.evidence and profile.evidence.reproduction_context
|
||||
lines[#lines+1]="Reproduction: disabled / incomplete species-specific lifecycle evidence. "
|
||||
..safe(evidence and evidence.note or "No breeding policy is enabled for this profile.",220)
|
||||
end
|
||||
local status=hearth.wildlife
|
||||
if type(status)=="table" and type(status.status)=="function" then
|
||||
local status_ok,data=pcall(status.status)
|
||||
if status_ok and type(data)=="table" and type(data.counts)=="table" then
|
||||
local n=tonumber(data.counts[id]); if n and n==n and math.abs(n)<math.huge then
|
||||
lines[#lines+1]="Active loaded animals of this species: "..math.max(0,math.floor(n))
|
||||
end
|
||||
end
|
||||
end
|
||||
local source=safe(type(record.source_url)=="string" and record.source_url or "source unavailable",300)
|
||||
local lifecycle_source=safe(type(profile.source_url)=="string" and profile.source_url or "UNKNOWN",300)
|
||||
C.show_formspec(name,"hearth:fauna_detail",journal(14,10)
|
||||
..label(0.65,0.55,"eom.dev / FAUNA FIELD NOTES",ink.mauve)
|
||||
..label(0.65,1.15,safe(record.common,80):upper().." / "..safe(record.latin or "identity unverified",100),ink.yellow)
|
||||
..divider(0.65,1.65,12.7)
|
||||
.."textarea[0.65,2.0;12.7,5.7;;;"..esc(table.concat(lines,"\n\n")).."]"
|
||||
.."textarea[0.65,7.8;12.7,0.8;;;"..esc("Identity source: "..source.."\nLifecycle source: "..lifecycle_source).."]"
|
||||
.."button[6.15,8.85;3.3,0.65;fauna_index;Back to fauna guide]"
|
||||
.."button_exit[10.05,8.85;3.3,0.65;close;Close]" )
|
||||
return true
|
||||
end
|
||||
|
||||
function hearth.fauna_guide(player,query,page)
|
||||
local name=player:get_player_name()
|
||||
local session=hearth.sessions[name]; if not session then return end
|
||||
query=tostring(query or ""):sub(1,48):gsub("[%c]"," ")
|
||||
local F=hearth.fauna
|
||||
local records=F and type(F.list)=="function" and F.list() or {}
|
||||
local observations=fauna_observations(player)
|
||||
local observed_count=0
|
||||
if observations then for _ in pairs(observations) do observed_count=observed_count+1 end end
|
||||
local all,all_ids,needle={},{},query:lower()
|
||||
local function timing(p,field)
|
||||
local r=p and p[field]
|
||||
if type(r)~="table" then return "UNKNOWN" end
|
||||
return tostring(r.min).."-"..tostring(r.max).." real days ["..tostring(r.confidence).."]"
|
||||
end
|
||||
for _,r in ipairs(records) do
|
||||
local p=r.profile
|
||||
local marker=observations and observations[r.id] and " / recorded encounter" or " / not yet encountered"
|
||||
local row=r.id.." / "..r.common.." / "..(r.latin or "UNKNOWN species identity (legacy deer)")..marker
|
||||
.."\nHabitat/movement medium: "..r.medium.."; activity: "..r.activity
|
||||
.."; food roles: "..words(r.foods)
|
||||
.."\nMaturity: "..timing(p,"maturity_days").."; longevity context: "..timing(p,"lifespan_days")
|
||||
.."\nLifecycle evidence: "..(p and p.confidence or "UNKNOWN")
|
||||
.."; automatic breeding "..(p and p.reproduction_enabled==true and "enabled only for sourced profiles" or "disabled (incomplete profile)")
|
||||
.."; no lifespan-endpoint death."
|
||||
.."\nIdentity source: "..r.source_url
|
||||
.."\nLifecycle source: "..(p and p.source_url or "UNKNOWN")
|
||||
if needle=="" or row:lower():find(needle,1,true) then all[#all+1]=row; all_ids[#all_ids+1]=r.id end
|
||||
end
|
||||
local pages_count=math.max(1,math.ceil(#all/5)); page=index(page,pages_count)
|
||||
local lines,choices={},{}
|
||||
session.fauna_ids,session.fauna_selected={},1
|
||||
for i=(page-1)*5+1,math.min(#all,page*5) do
|
||||
lines[#lines+1]=all[i]; session.fauna_ids[#session.fauna_ids+1]=all_ids[i]; choices[#choices+1]=esc(all_ids[i])
|
||||
end
|
||||
if #lines==0 then lines[1]=#records==0 and "Fauna catalogue unavailable in this runtime."
|
||||
or "No animal matches that literal search." end
|
||||
session.fauna_query,session.fauna_page=query,page
|
||||
C.show_formspec(name,"hearth:fauna",journal(14,10)
|
||||
..label(0.65,0.55,"eom.dev / FAUNA FIELD GUIDE",ink.mauve)
|
||||
..label(0.65,1.15,(observations and (observed_count.." / "..#records.." species recorded in your field journal")
|
||||
or "Field journal unavailable / source evidence is not complete biology"),ink.yellow)
|
||||
..divider(0.65,1.65,12.7)
|
||||
.."field[0.7,1.95;9.2,0.7;query;Species, scientific name, food or status;"..esc(query).."]field_close_on_enter[query;false]"
|
||||
.."button[10.25,1.95;3.1,0.7;search;Search]"
|
||||
..label(0.7,2.95,"Page "..page.." / "..pages_count.." / "..#all.." matches",ink.muted)
|
||||
.."textarea[0.65,3.3;12.7,4.0;;;"..esc(table.concat(lines,"\n\n")).."]"
|
||||
.."textarea[0.65,7.4;12.7,0.8;;;"..esc("Real calendar age includes unloaded time; no offline meals, births or event replay. Movement and feeding cadence are conservative game policies, not complete true-life behavior. No web requests or world changes.").."]"
|
||||
..(#choices>0 and "dropdown[0.7,8.25;5.0,0.45;species_choice;"..table.concat(choices,",")..";1;true]"
|
||||
..tooltip("species_choice","Choose one of the species shown on this page.")
|
||||
.."button[6,8.2;3.3,0.55;species_details;Species details]" or "")
|
||||
..(page>1 and "button[0.7,8.85;2.2,0.65;previous;Previous]" or "")
|
||||
..(page<pages_count and "button[3.05,8.85;2.2,0.65;next;Next]" or "")
|
||||
.."button[6,8.85;3.3,0.65;botany_index;Botanical guide]"
|
||||
.."button[10.05,8.85;3.3,0.65;field_notes;Back to field notes]")
|
||||
end
|
||||
|
||||
function hearth.inventory(player)
|
||||
player:set_inventory_formspec(journal(12.4,11.3)
|
||||
.. label(0.6,0.45,"eom.dev / THE LAST HEARTH",ink.mauve)
|
||||
@@ -307,9 +502,9 @@ function hearth.inventory(player)
|
||||
.. label(4.8,2.2,"RESULT",ink.teal) .. label(6.5,1.95,"GATHER / BUILD / BELONG",ink.yellow)
|
||||
.. "textarea[6.5,2.4;5.2,1.7;;;Collect flint and sticks first.\nA roof keeps rain off.\nA hearth makes shelter a home.]"
|
||||
.. "button[7.1,4.35;3.8,0.6;field_guide;Open field notes]"
|
||||
.. "tooltip[field_guide;Read survival notes, recipes and the ecosystem survey.;" .. ink.mantle .. ";" .. ink.text .. "]"
|
||||
.. tooltip("field_guide","Read survival notes, recipes and the ecosystem survey.")
|
||||
.. "button[7.1,3.65;3.8,0.6;character_creator;Character appearance]"
|
||||
.. "tooltip[character_creator;Preview woodland clothing and features. Cosmetic only; Save / Apply to keep changes.]"
|
||||
.. tooltip("character_creator","Preview woodland clothing and features. Cosmetic only; Save / Apply to keep changes.")
|
||||
.. label(0.6,5.25,"SATCHEL / first row is your hotbar",ink.muted)
|
||||
.. "list[current_player;main;0.6,5.7;10,4;]listring[current_player;main]listring[current_player;craft]")
|
||||
end
|
||||
@@ -322,8 +517,8 @@ function hearth.location_form(player, error_text)
|
||||
.. "box[0.6,3.95;4.5,0.8;" .. ink.mantle .. "]box[5.55,3.95;4.5,0.8;" .. ink.mantle .. "]"
|
||||
.. "field[0.65,4;4.4,0.7;latitude;Latitude (-90 to 90);]field_close_on_enter[latitude;false]"
|
||||
.. "field[5.6,4;4.4,0.7;longitude;Longitude (-180 to 180);]field_close_on_enter[longitude;false]"
|
||||
.. "tooltip[latitude;Approximate decimal degrees, at most 16 characters.;" .. ink.mantle .. ";" .. ink.text .. "]"
|
||||
.. "tooltip[longitude;Local world only. Shared servers use administrator-selected coordinates.;" .. ink.mantle .. ";" .. ink.text .. "]"
|
||||
.. tooltip("latitude","Approximate decimal degrees, at most 16 characters.")
|
||||
.. tooltip("longitude","Local world only. Shared servers use administrator-selected coordinates.")
|
||||
.. "textarea[0.65,4.95;9.5,0.75;;;" .. esc(C.colorize(error_text and ink.danger or ink.muted,
|
||||
tostring(error_text or "No location chosen: neutral equatorial preview until configured."):sub(1,180))) .. "]"
|
||||
.. "button[0.65,6;4.4,0.7;save_location;Use this location]"
|
||||
@@ -359,9 +554,27 @@ function hearth.ecosystem(player)
|
||||
.. "\nAquatic niche: " .. (type(h.aquatic) == "string" and h.aquatic:sub(1,32) or "unavailable")
|
||||
.. " Water depth: " .. (h.near_water ~= nil and optional(h.water) or "unavailable")
|
||||
end
|
||||
local fauna = w and string.format("Hares: %d Deer: %d Foxes: %d\nActive total: %d / %d globally Local cap: %d Per-player cap: %d",
|
||||
number(w.hare),number(w.deer),number(w.fox),number(w.total),number(w.global_cap),number(w.local_cap),number(w.player_cap))
|
||||
or "Wildlife observations are not available in this runtime."
|
||||
local fauna = "Wildlife observations are not available in this runtime."
|
||||
if w then
|
||||
local counts,lines=w.counts,{}
|
||||
-- Legacy optional API fallback; production uses every returned species.
|
||||
counts=type(counts)=="table" and counts or {hare=w.hare,deer=w.deer,fox=w.fox}
|
||||
local ids={}; for id in pairs(counts) do if type(id)=="string" then ids[#ids+1]=id end end
|
||||
table.sort(ids)
|
||||
for i=1,math.min(#ids,64) do
|
||||
local id=ids[i]
|
||||
local name=({hare="Hares",deer="Deer",fox="Foxes"})[id] or id:sub(1,64):gsub("_"," ")
|
||||
lines[#lines+1]=name .. ": " .. number(counts[id])
|
||||
end
|
||||
fauna=table.concat(lines," / ") .. string.format("\nActive total: %d / %d globally Local cap: %d Per-player cap: %d",
|
||||
number(w.total),number(w.global_cap),number(w.local_cap),number(w.player_cap))
|
||||
local recruitment=w.recruitment
|
||||
if type(recruitment)=="table" then
|
||||
local reason=type(recruitment.last_reason)=="string" and recruitment.last_reason:sub(1,40):gsub("[%c]"," ") or "unavailable"
|
||||
fauna=fauna..string.format("\nAdmissions: %d / %d attempts Latest outcome: %s",
|
||||
number(recruitment.successes),number(recruitment.attempts),reason)
|
||||
end
|
||||
end
|
||||
C.show_formspec(player:get_player_name(), "hearth:ecosystem", journal(14,10)
|
||||
.. label(0.65,0.55,"eom.dev / LIVING ECOSYSTEMS",ink.mauve)
|
||||
.. label(0.65,1.15,"A MOMENT IN THE MEADOW",ink.yellow) .. divider(0.65,1.65,12.7)
|
||||
@@ -370,11 +583,12 @@ function hearth.ecosystem(player)
|
||||
.. label(0.7,4.85,h and (h.loaded and "Loaded sample / natural-origin plant counts; ground and shade include terrain" or "Incomplete sample / unloaded cells are unknown, not empty") or "Sample unavailable",
|
||||
h and h.loaded and ink.muted or ink.yellow)
|
||||
.. label(0.7,5.15,"WILDLIFE / all currently active animals, not a local census",ink.teal)
|
||||
.. "textarea[0.65,5.55;12.7,1.25;;;" .. esc(fauna) .. "]"
|
||||
.. "textarea[0.65,7.15;12.7,1.05;;;" .. esc("Observation does not advance succession or spawn animals. No terrain is loaded.\nNatural succession pauses while unloaded; crops and planted saplings follow separate rules.") .. "]"
|
||||
.. "textarea[0.65,5.55;12.7,1.55;;;" .. esc(fauna) .. "]"
|
||||
.. "textarea[0.65,7.15;12.7,1.05;;;" .. esc("Observation does not advance succession or spawn animals. No terrain is loaded.\nCalendar age includes unloaded time; ecological work is bounded and loaded-only. No missed-event replay; unknown breeding is disabled.") .. "]"
|
||||
.. "button[0.7,8.85;2.8,0.65;refresh;Observe again]"
|
||||
.. "tooltip[refresh;Read one fresh sample without changing the world.;" .. ink.mantle .. ";" .. ink.text .. "]"
|
||||
.. tooltip("refresh","Read one fresh sample without changing the world.")
|
||||
.. "button[3.7,8.85;3.3,0.65;botany_index;Botanical guide]"
|
||||
.. "button[7.15,8.85;2.6,0.65;fauna_index;Fauna guide]"
|
||||
.. "button[10.05,8.85;3.3,0.65;field_notes;Back to field notes]")
|
||||
end
|
||||
|
||||
@@ -382,6 +596,27 @@ C.register_on_player_receive_fields(function(player, form, fields)
|
||||
if form == "hearth:species" then
|
||||
reader.handle(player,fields)
|
||||
return true
|
||||
elseif form == "hearth:fauna_detail" then
|
||||
local session=hearth.sessions[player:get_player_name()]
|
||||
if not session then return true end
|
||||
if fields.fauna_index then hearth.fauna_guide(player,session.fauna_query or "",session.fauna_page or 1) end
|
||||
return true
|
||||
elseif form == "hearth:fauna" then
|
||||
local session=hearth.sessions[player:get_player_name()]; if not session then return true end
|
||||
if fields.field_notes then hearth.guide(player,6)
|
||||
elseif fields.botany_index then hearth.botanical_guide(player,"",1)
|
||||
elseif fields.species_details then
|
||||
local value=fields.species_choice
|
||||
local n=type(value)=="string" and #value<=2 and value:match("^%d+$") and tonumber(value)
|
||||
if n and n>=1 and n<=#(session.fauna_ids or {}) then
|
||||
session.fauna_selected=n
|
||||
local id=(session.fauna_ids or {})[n]
|
||||
if id then hearth.inspect_fauna(player,id,nil) end
|
||||
end
|
||||
elseif fields.search or fields.key_enter then hearth.fauna_guide(player,fields.query,1)
|
||||
elseif fields.next then hearth.fauna_guide(player,session.fauna_query,(session.fauna_page or 1)+1)
|
||||
elseif fields.previous then hearth.fauna_guide(player,session.fauna_query,(session.fauna_page or 1)-1) end
|
||||
return true
|
||||
elseif form == "hearth:botany" then
|
||||
local session = hearth.sessions[player:get_player_name()]
|
||||
if not session then return true end
|
||||
@@ -391,7 +626,8 @@ C.register_on_player_receive_fields(function(player, form, fields)
|
||||
if not n or n<1 or n>#(session.botany_ids or {}) then return true end
|
||||
session.botany_selected = n
|
||||
end
|
||||
if fields.field_notes then hearth.guide(player,6)
|
||||
if fields.fauna_index then hearth.fauna_guide(player,"",1)
|
||||
elseif fields.field_notes then hearth.guide(player,6)
|
||||
elseif fields.ecosystem_survey then hearth.ecosystem(player)
|
||||
elseif fields.species_article then
|
||||
local id = (session.botany_ids or {})[session.botany_selected or 1]
|
||||
@@ -402,7 +638,8 @@ C.register_on_player_receive_fields(function(player, form, fields)
|
||||
return true
|
||||
elseif form == "hearth:ecosystem" then
|
||||
if not hearth.sessions[player:get_player_name()] then return true end
|
||||
if fields.field_notes then hearth.guide(player,6)
|
||||
if fields.fauna_index then hearth.fauna_guide(player,"",1)
|
||||
elseif fields.field_notes then hearth.guide(player,6)
|
||||
elseif fields.botany_index then hearth.botanical_guide(player,"",1)
|
||||
elseif fields.refresh then hearth.ecosystem(player) end
|
||||
return true
|
||||
@@ -420,6 +657,7 @@ C.register_on_player_receive_fields(function(player, form, fields)
|
||||
if fields.recipe_index then hearth.recipes(player, "", 1); return true end
|
||||
if fields.ecosystem_survey then hearth.ecosystem(player); return true end
|
||||
if fields.botany_index then hearth.botanical_guide(player,"",1); return true end
|
||||
if fields.fauna_index then hearth.fauna_guide(player,"",1); return true end
|
||||
if fields.inspect_target then
|
||||
local ok, message = inspect(player, "")
|
||||
if not ok and hearth.notify then hearth.notify(player,message) end
|
||||
@@ -463,6 +701,12 @@ C.register_chatcommand("botany", {params = "[search]", description = "Browse nam
|
||||
hearth.botanical_guide(player,param or "",1)
|
||||
return true
|
||||
end})
|
||||
C.register_chatcommand("fauna", {params="[search]",description="Observe animal catalogue sources and conservative lifecycle status",
|
||||
func=function(name,param)
|
||||
local player=C.get_player_by_name(name)
|
||||
if not player or not hearth.sessions[name] then return false,"Join the world first." end
|
||||
hearth.fauna_guide(player,param or "",1); return true
|
||||
end})
|
||||
C.register_chatcommand("inspect", {params = "[exact species ID or Latin name]",
|
||||
description = "Read a targeted species' offline Wikipedia excerpt and current game effects",
|
||||
func = function(name, param)
|
||||
|
||||
Executable
+387
@@ -0,0 +1,387 @@
|
||||
-- Pure calendar policy, Lua 5.1+. No engine, clock reads, registrations or writes.
|
||||
-- Ranges describe populations or explicit policy fallbacks, never death deadlines.
|
||||
local L = {}
|
||||
local DAY, YEAR, MAX_CLOCK = 86400, 365.2425, 9007199254740991
|
||||
local function finite(n) return type(n)=="number" and n==n and math.abs(n)<math.huge end
|
||||
local function clock(n) return finite(n) and n>=0 and n<=MAX_CLOCK end
|
||||
local function copy(t)
|
||||
if type(t)~="table" then return t end
|
||||
local out={}; for k,v in pairs(t) do out[k]=copy(v) end; return out
|
||||
end
|
||||
local function range(a,b,url,note,statistic)
|
||||
return {min=a,max=b,source_url=url or false,confidence=url and "source_reported" or "policy_fallback",
|
||||
note=note or "Unverified group policy; not a measured species range.",statistic=statistic or "policy_window"}
|
||||
end
|
||||
local function years(a,b,url,note,statistic) return range(a*YEAR,b*YEAR,url,note,statistic) end
|
||||
local WT="https://www.woodlandtrust.org.uk/trees-woods-and-wildlife/"
|
||||
local WILDLIFE="https://www.wildlifetrusts.org/wildlife-explorer/"
|
||||
local WIKI="https://en.wikipedia.org/wiki/"
|
||||
local ADW="https://animaldiversity.org/accounts/"
|
||||
local RSPB="https://www.rspb.org.uk/birds-and-wildlife/"
|
||||
local plants, animals = {}, {}
|
||||
local baseline={
|
||||
trees={range(14,365),years(1,5),years(10,60),years(60,500)},
|
||||
shrubs={range(14,365),years(1,2),years(2,10),years(5,80)},
|
||||
grasses={range(7,90),range(30,90),range(90,365),years(2,10)},
|
||||
herbs={range(7,90),range(90,365),range(365,730),years(2,10)},
|
||||
ferns_mosses={range(30,730),years(1,2),years(2,5),false},
|
||||
fungi={false,false,false,false}, aquatic={range(7,90),range(90,365),range(365,730),false},
|
||||
lichens={false,years(1,5),false,false},
|
||||
}
|
||||
local function plant_record(id,group,latin,pending)
|
||||
local b=baseline[group]
|
||||
plants[id]={id=id,kind="plant",group=group,latin=latin or false,pending=pending or false,
|
||||
germination_days=copy(b[1]),juvenile_days=copy(b[2]),maturity_days=copy(b[3]),lifespan_days=copy(b[4]),
|
||||
life_cycle=group=="fungi" and "not_applicable" or "perennial",
|
||||
life_cycle_confidence="policy_fallback",source_url=false,confidence="policy_fallback",
|
||||
evidence={},seasonal_policy="dormant_not_dead",age_origin="seed_or_propagule"}
|
||||
end
|
||||
-- Fixed snapshot of all 100 registered IDs; no catalogue factory is invoked here.
|
||||
local groups={
|
||||
trees="oak birch pine willow alder beech aspen rowan hazel spruce",
|
||||
shrubs="bilberry blackberry raspberry elder blackthorn hawthorn dog_rose holly juniper guelder_rose",
|
||||
grasses="sedge bulrush meadow_fescue cocksfoot timothy sweet_vernal_grass yorkshire_fog tufted_hairgrass common_reed soft_rush",
|
||||
herbs="clover nettle heather bluebell yarrow foxglove meadowsweet wild_garlic cowslip wood_anemone",
|
||||
ferns_mosses="bracken lady_fern male_fern harts_tongue polypody sphagnum haircap_moss moss fork_moss star_moss",
|
||||
fungi="fly_agaric birch_bolete bay_bolete chanterelle wood_blewit shaggy_inkcap turkey_tail oyster_fungus honey_fungus birch_polypore",
|
||||
aquatic="duckweed yellow_waterlily white_waterlily water_crowfoot water_mint water_forgetmenot brooklime water_starwort hornwort pondweed",
|
||||
lichens="reindeer_lichen oakmoss old_mans_beard lichen dog_lichen lungwort_lichen sunburst_lichen hammered_shield cup_lichen pixie_cup",
|
||||
}
|
||||
for group,text in pairs(groups) do for id in text:gmatch("%S+") do plant_record(id,group) end end
|
||||
-- The added twenty are registered by botany, not by this pure module.
|
||||
local extra_plants={
|
||||
{"dandelion","Taraxacum officinale"},{"daisy","Bellis perennis"},{"red_clover","Trifolium pratense"},
|
||||
{"birdsfoot_trefoil","Lotus corniculatus"},{"ribwort_plantain","Plantago lanceolata"},
|
||||
{"selfheal","Prunella vulgaris"},{"knapweed","Centaurea nigra"},{"oxeye_daisy","Leucanthemum vulgare"},
|
||||
{"field_scabious","Knautia arvensis"},{"teasel","Dipsacus fullonum"},{"cow_parsley","Anthriscus sylvestris"},
|
||||
{"garlic_mustard","Alliaria petiolata"},{"honeysuckle","Lonicera periclymenum","shrubs"},
|
||||
{"ivy","Hedera helix","shrubs"},{"gorse","Ulex europaeus","shrubs"},{"broom","Cytisus scoparius","shrubs"},
|
||||
{"crab_apple","Malus sylvestris","trees"},{"wild_cherry","Prunus avium","trees"},
|
||||
{"field_maple","Acer campestre","trees"},{"holly_fern","Polystichum setiferum","ferns_mosses"},
|
||||
}
|
||||
for _,r in ipairs(extra_plants) do plant_record(r[1],r[3] or "herbs",r[2],false) end
|
||||
local function evidence(p,field,url,note)
|
||||
p.evidence[field]={source_url=url,note=note,reviewed="2026-11-03"}
|
||||
p.source_url=p.source_url or url; p.confidence="partial_species_evidence"
|
||||
end
|
||||
local function field(p,name,value)
|
||||
p[name]=value; evidence(p,name,value.source_url,value.note)
|
||||
end
|
||||
-- All ten trees have distinct profiles. Unsourced timing fields retain their
|
||||
-- own policy_fallback label even when a different field has species evidence.
|
||||
local tree_rows={
|
||||
{"oak","Quercus robur",40,60,500,1000,"https://www.forestryengland.uk/article/english-oak"},
|
||||
{"birch","Betula pendula",10,20,60,90,"https://treesforlife.org.uk/into-the-forest/trees-plants-animals/trees/birch/birch-facts/"},
|
||||
{"pine","Pinus sylvestris",15,30,150,300,WIKI.."Pinus_sylvestris"},
|
||||
{"willow","Salix alba",5,15,60,120,WIKI.."Salix_alba"},
|
||||
{"alder","Alnus glutinosa",10,20,60,160,WIKI.."Alnus_glutinosa"},
|
||||
{"beech","Fagus sylvatica",10,30,300,500,WIKI.."Fagus_sylvatica"},
|
||||
{"aspen","Populus tremula",10,20,80,200,WIKI.."Populus_tremula"},
|
||||
{"rowan","Sorbus aucuparia",10,10,80,150,WIKI.."Sorbus_aucuparia"},
|
||||
{"hazel","Corylus avellana",3,10,60,80,WT.."british-trees/a-z-of-british-trees/hazel/"},
|
||||
{"spruce","Picea abies",20,40,200,532,WIKI.."Picea_abies"},
|
||||
}
|
||||
for _,r in ipairs(tree_rows) do
|
||||
local p=plants[r[1]]; p.latin=r[2]
|
||||
p.maturity_days=years(r[3],r[4])
|
||||
p.lifespan_days=years(r[5],r[6])
|
||||
evidence(p,"natural_history",r[7],"Species reference; only separately attributed fields are verified numerical claims.")
|
||||
end
|
||||
field(plants.oak,"maturity_days",years(40,40,tree_rows[1][7],"Acorns usually begin around 40 years; approximate report, not a universal minimum.","approximate_onset"))
|
||||
field(plants.oak,"lifespan_days",years(500,1000,WIKI.."Quercus_robur","Long-lived survivors often exceed 500 years; some exceed 1000. Not a mortality distribution.","longevity_envelope"))
|
||||
field(plants.birch,"lifespan_days",years(60,90,tree_rows[2][7],"Typical birch longevity; page covers silver and downy birch and notes exceptional ages above 150.","typical_range"))
|
||||
field(plants.pine,"lifespan_days",years(150,300,tree_rows[3][7],"Normal reported lifespan; exceptional trees exceed this range.","typical_range"))
|
||||
field(plants.alder,"lifespan_days",years(60,160,tree_rows[5][7],"Composite envelope: Woodland Trust approximately 60; Wikipedia up to 160. Not typical 60-160 survival.","composite_envelope"))
|
||||
evidence(plants.alder,"lifespan_context",WT.."british-trees/a-z-of-british-trees/alder/","Approximately 60 years; contrasts with upper longevity reports.")
|
||||
field(plants.beech,"maturity_days",years(10,30,tree_rows[6][7],"Small seed quantities around 10 years, heavy crops at least 30; different reproductive milestones.","onset_to_heavy_crop"))
|
||||
field(plants.beech,"lifespan_days",years(300,500,tree_rows[6][7],"Undisturbed lifespan around 300; exceptional individual above 500.","longevity_envelope"))
|
||||
field(plants.aspen,"lifespan_days",years(80,200,tree_rows[7][7],"Upper endpoint 200 reported; lower endpoint 80 is unverified policy, not a sourced range.","policy_envelope"))
|
||||
plants.aspen.lifespan_days.confidence="policy_fallback" -- source supports only its upper limit
|
||||
field(plants.rowan,"maturity_days",years(10,10,tree_rows[8][7],"Reported maturity at about ten years.","approximate_onset"))
|
||||
field(plants.rowan,"lifespan_days",years(80,150,tree_rows[8][7],"Often not older than 80 in warmer regions; occasional 150.","longevity_envelope"))
|
||||
field(plants.hazel,"lifespan_days",years(60,80,tree_rows[9][7],"Uncoppiced upper limit 80 reported; lower endpoint 60 is policy. Coppice stools can persist centuries.","policy_envelope"))
|
||||
plants.hazel.lifespan_days.confidence="policy_fallback"
|
||||
field(plants.spruce,"lifespan_days",years(200,532,tree_rows[10][7],"532-year ring-dated individual; lower endpoint 200 is policy, not sourced. Never use clonal root ages as stem ages.","policy_envelope"))
|
||||
plants.spruce.lifespan_days.confidence="policy_fallback"
|
||||
for _,id in ipairs({"pine","spruce","holly","ivy","gorse","holly_fern","harts_tongue","polypody"}) do
|
||||
plants[id].seasonal_policy="retain_foliage_policy"
|
||||
end
|
||||
for _,id in ipairs({"fungi","lichens","ferns_mosses"}) do
|
||||
for _,p in pairs(plants) do if p.group==id then p.age_origin="propagule_or_colony" end end
|
||||
end
|
||||
local garlic=WILDLIFE.."wildflowers/garlic-mustard"
|
||||
plants.garlic_mustard.life_cycle="biennial"; plants.garlic_mustard.life_cycle_confidence="source_reported"
|
||||
evidence(plants.garlic_mustard,"life_cycle",garlic,"First-season leaves retained over winter; flowers in second spring.")
|
||||
plants.garlic_mustard.maturity_days=range(365,730,false,"Second-season policy window; not an exact source-measured number of days.")
|
||||
plants.garlic_mustard.lifespan_days=range(365,730,false,"Two-season policy; do not kill at the first frost.")
|
||||
local foxglove=WILDLIFE.."wildflowers/foxglove"
|
||||
plants.foxglove.life_cycle="biennial"; plants.foxglove.life_cycle_confidence="source_reported"
|
||||
evidence(plants.foxglove,"life_cycle",foxglove,"Flower stems in second year; biennial representation, not a guaranteed death date.")
|
||||
plants.foxglove.maturity_days=copy(plants.garlic_mustard.maturity_days)
|
||||
-- Registered teasel remains explicitly unverified rather than inventing a citation.
|
||||
plants.teasel.life_cycle="biennial"
|
||||
|
||||
local function animal_record(id,latin,path,diet,movement,life_a,life_b)
|
||||
local url=WILDLIFE..path
|
||||
local p={id=id,latin=latin,kind="animal",pending=false,
|
||||
maturity_days=false,gestation_days=false,incubation_days=false,juvenile_days=false,lifespan_days=false,
|
||||
breeding_months=false,birth_count=false,birth_count_unit="young_per_litter",reproduction="unknown",
|
||||
diet=diet,movement=movement,source_url=url,confidence="partial_species_evidence",evidence={},
|
||||
age_origin="birth_or_hatching",reproduction_enabled=false}
|
||||
animals[id]=p
|
||||
evidence(p,"diet",url,diet); evidence(p,"movement",url,movement)
|
||||
if life_a then field(p,"lifespan_days",years(life_a,life_b,url,"Reported average lifespan, not maximum age or a death deadline.","reported_average")) end
|
||||
return p
|
||||
end
|
||||
animal_record("rabbit","Oryctolagus cuniculus","mammals/rabbit","grass and other vegetation","hopping; underground warrens",3,3)
|
||||
animal_record("red_squirrel","Sciurus vulgaris","mammals/red-squirrel","nuts and conifer seeds","tree climbing and branch leaping; no hibernation",6,6)
|
||||
animal_record("bank_vole","Myodes glareolus","mammals/bank-vole","fruit, nuts and small insects","agile; shallow burrows; no hibernation",.5,1.5)
|
||||
animal_record("wood_mouse","Apodemus sylvaticus","mammals/wood-mouse","berries and seeds","mostly nocturnal; climbing and food caching",1,1)
|
||||
animal_record("hedgehog","Erinaceus europaeus","mammals/european-hedgehog","invertebrates including beetles and earthworms","nocturnal; winter hibernation",2,3)
|
||||
animal_record("badger","Meles meles","mammals/european-badger","worms, fruit, plants, eggs and small animals","digging; family groups in setts",5,8)
|
||||
animal_record("roe_deer","Capreolus capreolus","mammals/roe-deer","tree and shrub buds and leaves; herbs and grasses","solitary in summer; loose winter groups",7,7)
|
||||
animal_record("stoat","Mustela erminea","mammals/stoat","small rodents and rabbits","bounding gait; active day and night",2,5)
|
||||
animal_record("weasel","Mustela nivalis","mammals/weasel","voles, mice and small birds","low running gait; enters rodent burrows",2,2)
|
||||
animal_record("otter","Lutra lutra","mammals/european-otter","fish, amphibians, waterbirds and crustaceans","swimming; sheltered holts",5,10)
|
||||
local function bird(id,latin,diet,movement,life)
|
||||
local p=animal_record(id,latin,"unused",diet,movement)
|
||||
p.source_url=RSPB..id:gsub("_","-"); p.evidence={}
|
||||
evidence(p,"diet",p.source_url,diet); evidence(p,"movement",p.source_url,movement)
|
||||
p.reproduction="egg"; p.birth_count_unit="eggs_per_clutch"
|
||||
if life then field(p,"lifespan_days",years(life,life,p.source_url,"Reported average, not maximum or mandatory death age.","reported_average")) end
|
||||
end
|
||||
bird("robin","Erithacus rubecula","worms, insects, other invertebrates, seeds and fruit","hopping; territorial; mostly UK resident")
|
||||
bird("blackbird","Turdus merula","worms, insects, berries and fruit","ground hopping; some seasonal migration",3)
|
||||
bird("blue_tit","Cyanistes caeruleus","insects, caterpillars, seeds and nuts","clinging and flight; winter feeding flocks",3)
|
||||
bird("barn_owl","Tyto alba","mice, voles, shrews and small birds","low silent hunting flight, often dawn or dusk")
|
||||
bird("mallard","Anas platyrhynchos","seeds, plants, insects and shellfish","swimming and flight; resident or migratory")
|
||||
animal_record("common_frog","Rana temporaria","amphibians/common-frog","invertebrates and smaller amphibians","hopping and swimming; winter shelter",5,10)
|
||||
animal_record("smooth_newt","Lissotriton vulgaris","amphibians/smooth-newt","land invertebrates; aquatic crustaceans, molluscs and tadpoles","aquatic breeding; nocturnal land activity; winter shelter",6,6)
|
||||
animal_record("brown_trout","Salmo trutta","freshwater-fish/brown-trout","insect larvae, small fish and flying insects","river swimming; freshwater resident form",15,20)
|
||||
animal_record("bumblebee","Bombus terrestris","invertebrates/bees-and-wasps/buff-tailed-bumblebee","floral nectar and pollen","flight; underground colony; some winter-active colonies")
|
||||
local worm=animal_record("earthworm","Lumbricus terrestris","unused","dead leaf litter and mineral soil","vertical burrows; moist-surface movement")
|
||||
worm.source_url=WIKI.."Lumbricus_terrestris"; worm.evidence={}
|
||||
evidence(worm,"diet",worm.source_url,worm.diet); evidence(worm,"movement",worm.source_url,worm.movement)
|
||||
evidence(worm,"lifespan_context",worm.source_url,"Natural lifespan unknown; six years in captivity is not a wild lifespan range.")
|
||||
field(worm,"maturity_days",years(1,2,worm.source_url,"At least one year development; reproductive maturity in second year.","development_window"))
|
||||
worm.reproduction="paired_hermaphrodite_cocoon"
|
||||
animal_record("hare","Lepus europaeus","mammals/brown-hare","vegetation and young woody bark","bounding; surface forms, not rabbit warrens",2,4)
|
||||
animal_record("fox","Vulpes vulpes","mammals/red-fox","small mammals, birds, amphibians, worms and fruit","mostly nocturnal; territorial scent marking",2,3)
|
||||
-- Generic legacy deer is deliberately NOT silently reidentified as roe deer.
|
||||
animals.deer={id="deer",kind="animal",latin=false,pending=false,confidence="unverified_identity",source_url=false,
|
||||
evidence={},maturity_days=false,juvenile_days=false,gestation_days=false,incubation_days=false,
|
||||
lifespan_days=false,breeding_months=false,birth_count=false,reproduction="unknown",reproduction_enabled=false}
|
||||
local function count(id,a,b)
|
||||
local p=animals[id]; field(p,"birth_count",range(a,b,p.source_url,"Source-reported litter or clutch count; not guaranteed survivors.","offspring_count"))
|
||||
end
|
||||
count("rabbit",3,7); count("bank_vole",3,5); count("wood_mouse",4,8); count("stoat",6,12)
|
||||
count("weasel",3,6); count("roe_deer",2,3); count("hare",2,4)
|
||||
count("robin",4,5); count("blackbird",3,4)
|
||||
local function months(id,list,url,note)
|
||||
local p=animals[id]; p.breeding_months=list; evidence(p,"breeding_months",url or p.source_url,note)
|
||||
end
|
||||
months("roe_deer",{7,8},nil,"Mating July-August; delayed implantation; births May-June, not immediate gestation completion.")
|
||||
animals.roe_deer.reproduction="delayed_implantation"
|
||||
animals.stoat.reproduction="delayed_implantation"
|
||||
evidence(animals.stoat,"reproduction",animals.stoat.source_url,"Summer mating, implantation delayed until following spring; no unsupported numeric gestation.")
|
||||
months("blackbird",{3,4,5,6,7,8},nil,"UK nesting usually March-August, not a universal physiological limit.")
|
||||
months("blue_tit",{4,5,6},nil,"UK nesting April-June; normally one brood annually.")
|
||||
local hare=animals.hare
|
||||
field(hare,"maturity_days",range(236,365.2425,ADW.."Lepus_europaeus/","Sexual maturity reported from about eight months to one year; conservative runtime uses the upper endpoint.","reported_range"))
|
||||
field(hare,"gestation_days",range(30,42,ADW.."Lepus_europaeus/","Reported gestation range; several litters can occur during the breeding season.","reported_range"))
|
||||
field(hare,"juvenile_days",range(30,30,ADW.."Lepus_europaeus/","About one month to weaning/independence; leverets are precocial at birth.","approximate_duration"))
|
||||
field(hare,"birth_count",range(1,8,ADW.."Lepus_europaeus/","Reported range is 1-8 leverets; runtime uses the conservative lower endpoint.","offspring_count"))
|
||||
months("hare",{1,2,3,4,5,6,7},ADW.."Lepus_europaeus/","Midwinter (January/February) through midsummer in the cited northern-range account.")
|
||||
hare.reproduction="live_birth"; hare.reproduction_enabled=true
|
||||
hare.breeding_seasons={Winter=true,Spring=true,Summer=true}; hare.season_policy="sourced_midwinter_to_midsummer_coarse_seasons"
|
||||
hare.postpartum_days=30; hare.care_confidence="source_reported"
|
||||
local fox=animals.fox
|
||||
field(fox,"maturity_days",range(304,304,ADW.."Vulpes_vulpes/","ADW reports maturity at about ten months (304-day entry); both sexes are reported similarly.","reported_average"))
|
||||
field(fox,"gestation_days",range(51,53,ADW.."Vulpes_vulpes/","Typical gestation is 51-53 days; the account also notes a broader 49-56-day span.","typical_range"))
|
||||
field(fox,"juvenile_days",range(56,70,ADW.."Vulpes_vulpes/","Nursing/weaning lasts about 56-70 days; leaving the den at 4-5 weeks is not independence.","weaning"))
|
||||
field(fox,"birth_count",range(1,9,ADW.."Vulpes_vulpes/","ADW's summary table gives 1-9 pups, while its prose gives 1-13; use the narrower table range and lower endpoint.","source_disagreement_envelope"))
|
||||
months("fox",{12,1,2},ADW.."Vulpes_vulpes/","Central-range mating is reported in January-February; winter is the conservative coarse runtime window.")
|
||||
fox.reproduction="live_birth"; fox.reproduction_enabled=true
|
||||
fox.breeding_seasons={Winter=true}; fox.season_policy="central_range_winter_policy"
|
||||
fox.postpartum_days=300; fox.care_confidence="policy_fallback"
|
||||
-- Winter dormancy is a coarse loaded-world behavior, distinct from death or
|
||||
-- a measured metabolic rate. These taxa retain their saved residents.
|
||||
animals.hedgehog.winter_dormant=true
|
||||
animals.common_frog.winter_dormant=true
|
||||
animals.smooth_newt.winter_dormant=true
|
||||
local rabbit=animals.rabbit
|
||||
field(rabbit,"gestation_days",range(30,37,ADW.."Oryctolagus_cuniculus/","ADW reproduction reports 30-37 days; approximately 30 typical.","reported_range"))
|
||||
field(rabbit,"juvenile_days",range(22,31,ADW.."Oryctolagus_cuniculus/","Weaning age, not sexual maturity.","weaning"))
|
||||
-- ADW rabbit maturity prose (8 months) conflicts with an embedded 730-day
|
||||
-- value. Keep maturity unknown rather than laundering that discrepancy.
|
||||
evidence(rabbit,"maturity_context",ADW.."Oryctolagus_cuniculus/","Conflicting maturity values on this page: not accepted as a verified threshold.")
|
||||
months("rabbit",{1,2,3,4,5,6,7,8,9,10,11,12},ADW.."Oryctolagus_cuniculus/","Can breed year-round, mostly first half of year; habitat/health still required.")
|
||||
local squirrel=animals.red_squirrel
|
||||
field(squirrel,"gestation_days",range(38,39,ADW.."Sciurus_vulgaris/","Gestation 38-39 days.","reported_range"))
|
||||
field(squirrel,"juvenile_days",range(56,70,ADW.."Sciurus_vulgaris/","Independence eight to ten weeks, not sexual maturity.","independence"))
|
||||
field(squirrel,"maturity_days",range(296,365.2425,ADW.."Sciurus_vulgaris/","Composite envelope of female 296-day entry, male 320-day entry and approximately one-year narrative.","composite_envelope"))
|
||||
-- Source gives birth/litter months, not conception months: no month override.
|
||||
local vole=animals.bank_vole
|
||||
field(vole,"maturity_days",range(42,56,WT.."animals/mammals/bank-vole/","Females six weeks, males eight weeks; use sex-specific values if available.","sex_envelope"))
|
||||
field(vole,"juvenile_days",range(25,25,WT.."animals/mammals/bank-vole/","Weaning about 25 days.","weaning"))
|
||||
local mouse=animals.wood_mouse
|
||||
field(mouse,"gestation_days",range(21,26,ADW.."Apodemus_sylvaticus/","Gestation range 21-26 days.","reported_range"))
|
||||
field(mouse,"juvenile_days",range(21,21,ADW.."Apodemus_sylvaticus/","Weaning at three weeks.","weaning"))
|
||||
field(mouse,"maturity_days",range(65,71,ADW.."Apodemus_sylvaticus/","Male 65-day and female 71-day entries; narrative approximately two months.","sex_envelope"))
|
||||
field(animals.common_frog,"larval_days",range(98,98,animals.common_frog.source_url,"About 14 weeks AFTER hatching to froglet; not maturity or egg incubation.","metamorphosis"))
|
||||
animals.common_frog.reproduction="external_eggs"; animals.smooth_newt.reproduction="eggs"
|
||||
animals.brown_trout.reproduction="external_eggs"; animals.bumblebee.reproduction="colony_caste_dependent"
|
||||
-- Additional retrieved reproductive accounts, reviewed 2026-11-03. Registration
|
||||
-- status is independent of evidence. A lifespan is NOT a breeding prerequisite.
|
||||
field(vole,"gestation_days",range(17,24,ADW.."Myodes_glareolus/","17-24 days, mean 21; upper endpoint includes lactation delay.","reported_range"))
|
||||
months("bank_vole",{4,5,6,7,8,9},ADW.."Myodes_glareolus/","Late April-September; runtime uses conservative Spring/Summer season policy.")
|
||||
months("wood_mouse",{2,3,4,5,6,7,8,9,10},WIKI.."Wood_mouse","February-October in this account; ADW instead says March-early winter.")
|
||||
local blackbird=animals.blackbird
|
||||
field(blackbird,"maturity_days",years(1,1,ADW.."Turdus_merula/","Usually starts mating at one year.","approximate_onset"))
|
||||
field(blackbird,"incubation_days",range(13,14,ADW.."Turdus_merula/","13-day prose and 14-day summary; conservative composite.","composite_envelope"))
|
||||
field(blackbird,"juvenile_days",range(13,14,ADW.."Turdus_merula/","Leaving nest at 13-14 days; NOT full independence.","fledging"))
|
||||
local mallard=animals.mallard
|
||||
field(mallard,"maturity_days",range(365,365,ADW.."Anas_platyrhynchos/","Both sexes reported at 365 days; yearling breeding.","reported_average"))
|
||||
field(mallard,"incubation_days",range(26,28,ADW.."Anas_platyrhynchos/","Time to hatching 26-28 days.","reported_range"))
|
||||
field(mallard,"birth_count",range(9,13,ADW.."Anas_platyrhynchos/","Typical hen lays 9-13 eggs; not guaranteed survivors.","clutch_count"))
|
||||
field(mallard,"juvenile_days",range(50,60,WIKI.."Mallard","50-60 days to flight; not reproductive maturity.","fledging"))
|
||||
local blue=animals.blue_tit
|
||||
local diary="https://www.bto.org/understanding-birds/articles/blue-tit-diary"
|
||||
field(blue,"incubation_days",range(14,14,diary,"About two weeks; approximate, not an exact physiological constant.","approximate_duration"))
|
||||
field(blue,"birth_count",range(8,10,diary,"Majority of clutches contain 8-10 eggs; larger clutches exist.","typical_clutch"))
|
||||
field(blue,"juvenile_days",range(18,21,diary,"Fledging 18-21 days; parents feed for further weeks.","fledging"))
|
||||
evidence(blue,"maturity_context",diary,"Usually breeds in spring after hatching; no exact day threshold accepted. Caterpillar provisioning absent, so disabled.")
|
||||
field(animals.common_frog,"maturity_days",years(3,3,WIKI.."Common_frog","Sexual maturity after three years in this account; local variation exists.","approximate_onset"))
|
||||
field(animals.common_frog,"incubation_days",range(14,21,WIKI.."Common_frog","Eggs hatch after 2-3 weeks; temperature-dependent, NOT froglet age.","reported_range"))
|
||||
evidence(animals.brown_trout,"reproduction_context",WIKI.."Brown_trout","About 2000 eggs/kg; requires female mass, gravel redd and temperature-dependent development, not a fixed litter.")
|
||||
evidence(animals.bumblebee,"reproduction_context",WIKI.."Bombus_terrestris","Queen-founded colonies, diploid females/haploid males, larvae and pupae; caste-specific care cannot use a mammalian litter timer.")
|
||||
-- Explicit seasonal/care policies, NOT source-reported month precision or a
|
||||
-- claim to model nests/physiology. Upper dependency endpoints avoid postpartum
|
||||
-- immediate breeding; bird care lock is deliberately longer than fledging.
|
||||
for _,id in ipairs({"bank_vole","wood_mouse","blackbird","mallard"}) do
|
||||
local p=animals[id]
|
||||
p.reproduction_enabled=true
|
||||
p.reproduction=(id=="bank_vole" or id=="wood_mouse") and "live_birth" or "egg"
|
||||
p.breeding_seasons={Spring=true,Summer=true}
|
||||
p.season_policy="conservative_temperate_spring_summer"
|
||||
p.postpartum_days=math.max(21,p.juvenile_days.max)+(p.reproduction=="egg" and 21 or 0)
|
||||
p.care_confidence="policy_fallback"
|
||||
end
|
||||
|
||||
local function id_ok(id) return type(id)=="string" and #id<=64 and id:match("^[a-z][a-z_]*$")~=nil end
|
||||
function L.plant(id,group)
|
||||
if not id_ok(id) then return nil,"id" end
|
||||
if plants[id] then
|
||||
if group~=nil and group~=plants[id].group then return nil,"group" end
|
||||
return copy(plants[id])
|
||||
end
|
||||
return nil,"unknown_species" -- never fabricate an identity from a group hint
|
||||
end
|
||||
function L.animal(id)
|
||||
if not id_ok(id) then return nil,"id" end
|
||||
if not animals[id] then return nil,"unknown_species" end
|
||||
return copy(animals[id])
|
||||
end
|
||||
function L.ids(kind)
|
||||
local out={}; local src=kind=="plant" and plants or kind=="animal" and animals
|
||||
if not src then return nil,"kind" end
|
||||
for id in pairs(src) do out[#out+1]=id end; table.sort(out); return out
|
||||
end
|
||||
local function valid_range(r)
|
||||
return type(r)=="table" and finite(r.min) and finite(r.max) and r.min>=0 and r.max>=r.min and r.max<=1000000
|
||||
end
|
||||
-- Stable numeric seed, field salt <=64 bytes, bounded floating-point arithmetic.
|
||||
-- Reproducible selection only; NOT cryptography and never math.randomseed().
|
||||
function L.duration(r,seed,salt)
|
||||
if not valid_range(r) then return nil,"range" end
|
||||
if not finite(seed) or seed<0 or seed>2147483646 or seed%1~=0 then return nil,"seed" end
|
||||
if type(salt)~="string" or #salt>64 then return nil,"salt" end
|
||||
local h=seed+1.0
|
||||
for i=1,#salt do h=(h*131.0+salt:byte(i))%2147483647.0 end
|
||||
h=(h*48271.0)%2147483647.0
|
||||
return r.min+(r.max-r.min)*(h/2147483647.0)
|
||||
end
|
||||
local durations={"germination_days","juvenile_days","maturity_days","lifespan_days","gestation_days","incubation_days","larval_days"}
|
||||
function L.resolve(profile,seed)
|
||||
if type(profile)~="table" or not id_ok(profile.id) then return nil,"profile" end
|
||||
if not finite(seed) or seed<0 or seed>2147483646 or seed%1~=0 then return nil,"seed" end
|
||||
local out=copy(profile); out.selected_days={}
|
||||
for _,name in ipairs(durations) do
|
||||
if profile[name] then
|
||||
local n,err=L.duration(profile[name],seed,profile.id..":"..name)
|
||||
if not n then return nil,err end
|
||||
out.selected_days[name]=n
|
||||
end
|
||||
end
|
||||
-- Overlapping stage ranges must not produce maturity before dependency or
|
||||
-- germination. Clamp to the preceding selected endpoint, never outside a range.
|
||||
local previous=0
|
||||
for _,name in ipairs({"germination_days","juvenile_days","maturity_days","lifespan_days"}) do
|
||||
local n=out.selected_days[name]
|
||||
if n then
|
||||
n=math.max(previous,n)
|
||||
if n>profile[name].max then return nil,"profile_order" end
|
||||
out.selected_days[name]=n; previous=n
|
||||
end
|
||||
end
|
||||
out.selection_seed=seed; return out
|
||||
end
|
||||
function L.elapsed(previous,now,work_cap_seconds)
|
||||
if not clock(previous) or not clock(now) then return nil,"clock" end
|
||||
if now<previous then return nil,"clock_reversed" end
|
||||
if not clock(work_cap_seconds) then return nil,"cap" end
|
||||
local age_delta=now-previous; local work=math.min(age_delta,work_cap_seconds)
|
||||
return {elapsed_seconds=age_delta,work_seconds=work,deferred_seconds=age_delta-work}
|
||||
end
|
||||
local function threshold(profile,name)
|
||||
local r=profile[name]
|
||||
if r==false or r==nil then return false end
|
||||
if not valid_range(r) then return nil end
|
||||
if profile.selected_days~=nil and type(profile.selected_days)~="table" then return nil end
|
||||
local selected=profile.selected_days and profile.selected_days[name]
|
||||
if selected~=nil then
|
||||
if not finite(selected) or selected<r.min or selected>r.max then return nil end
|
||||
return selected
|
||||
end
|
||||
return r.max -- conservative unsampled policy, not a random draw on each read
|
||||
end
|
||||
function L.phase(profile,born_at,now)
|
||||
if type(profile)~="table" or (profile.kind~="plant" and profile.kind~="animal") then return nil,"profile" end
|
||||
if not clock(born_at) or not clock(now) then return nil,"clock" end
|
||||
if now<born_at then return nil,"clock_reversed" end
|
||||
local age=(now-born_at)/DAY
|
||||
local g,j,m,life=threshold(profile,"germination_days"),threshold(profile,"juvenile_days"),
|
||||
threshold(profile,"maturity_days"),threshold(profile,"lifespan_days")
|
||||
if g==nil or j==nil or m==nil or life==nil then return nil,"profile" end
|
||||
if (g and j and g>j) or (j and m and j>m) or (m and life and m>life) then return nil,"profile_order" end
|
||||
local phase="unknown"
|
||||
if profile.kind=="plant" and g and age<g then phase="seed"
|
||||
elseif j and age<j then phase=profile.kind=="plant" and "seedling" or "dependent"
|
||||
elseif m and age<m then phase="juvenile"
|
||||
elseif m then phase="mature" end
|
||||
local beyond=life and age>=life or false
|
||||
return {phase=phase,age_days=age,age_seconds=now-born_at,beyond_lifespan_range=beyond,
|
||||
-- A population average or record age must NEVER be an automatic death.
|
||||
death_due=false,reproductive_ready=false,confidence=profile.confidence}
|
||||
end
|
||||
function L.seasonal(profile,season)
|
||||
if type(profile)~="table" or profile.kind~="plant" then return nil,"profile" end
|
||||
if not ({Spring=true,Summer=true,Autumn=true,Winter=true})[season] then return nil,"season" end
|
||||
return {dormant=season=="Winter" and profile.seasonal_policy=="dormant_not_dead",death_due=false,
|
||||
confidence="policy_fallback"} -- presentation only; not a frost or survival model
|
||||
end
|
||||
function L.breeding_window(profile,month)
|
||||
if type(profile)~="table" or profile.kind~="animal" then return nil,"profile" end
|
||||
if not finite(month) or month%1~=0 or month<1 or month>12 then return nil,"month" end
|
||||
local list=profile.breeding_months
|
||||
if list==false or list==nil then return {known=false,within_window=false,eligible=false} end
|
||||
if type(list)~="table" or #list>12 then return nil,"profile" end
|
||||
local allowed=false
|
||||
for _,m in ipairs(list) do
|
||||
if not finite(m) or m%1~=0 or m<1 or m>12 then return nil,"profile" end
|
||||
if m==month then allowed=true end
|
||||
end
|
||||
return {known=true,within_window=allowed,eligible=false} -- window alone never authorizes reproduction
|
||||
end
|
||||
L.VERSION=1; L.SECONDS_PER_DAY=DAY; L.DAYS_PER_YEAR=YEAR; L.MAX_CLOCK=MAX_CLOCK
|
||||
return L
|
||||
@@ -1,5 +1,6 @@
|
||||
name = hearth
|
||||
title = The Last Hearth
|
||||
description = Original survival-first eom.dev prototype. No external game dependencies.
|
||||
description = Survival-first woodland game with original art and a pinned Creatura wildlife AI API.
|
||||
author = eom.dev
|
||||
min_minetest_version = 5.18
|
||||
depends = creatura
|
||||
|
||||
@@ -32,7 +32,7 @@ return function(hearth, api)
|
||||
local function record(value)
|
||||
return B.get(type(value)=="table" and value.id or value)
|
||||
end
|
||||
-- Identity references cover all 80 records. An identity link is not evidence
|
||||
-- Identity references cover all 100 records. An identity link is not evidence
|
||||
-- that every tagged trait has been reviewed or implemented.
|
||||
function E.source(value)
|
||||
local r=record(value); if not r then return nil end
|
||||
@@ -53,10 +53,10 @@ return function(hearth, api)
|
||||
local out={}; for _,r in ipairs(B.list()) do out[r.id]=E.source(r) end; return out
|
||||
end
|
||||
local tags={"nitrogenfixer","browse","pollinator","ectomycorrhiza","saprotroph","parasite","engineer","nursery","allelopathy","resin"}
|
||||
local nitrogen="After 900 warm active seconds: at most 1 nutrient per eligible evaluation, 2 total per support via succ_fixed_nitrogen shared across species/restarts; stops at fertility 4 (soil cap 8)."
|
||||
local nitrogen="After 90 real calendar days of root age, in warm active conditions: at most 1 nutrient per eligible evaluation, 2 total per support via succ_fixed_nitrogen shared across species/restarts; stops at fertility 4 (soil cap 8). Timing is game policy, not a sourced fixation rate."
|
||||
local descriptions={
|
||||
browse="Active natural plants count as wildlife forage; grazing consumes eligible plants, not mature trees. Not human food permission.",
|
||||
pollinator="Only natural plants in their catalogue flowering season add to pollination. This input does not itself change berry timers.",
|
||||
browse="Active natural plants count as wildlife forage; grazing debits a shared partial-canopy ration with a seven-real-day rest, not the perennial root. Not human food permission.",
|
||||
pollinator="Mature natural plants in their flowering season support pollination. Nectar visits reserve a shared one-hour nectar deadline and at most one pollination credit expiring after one real day. Human harvesting spends that credit for at most one extra berry from an existing fruit crop; it never resets annual fruit readiness or creates seeds.",
|
||||
nursery="Each active natural non-component plant adds nursery cover; any cover reduces succession stress accumulation to 75%. No extra animal spawn is promised.",
|
||||
parasite="Not simulated: no infection or attack on living trees or player plants.",
|
||||
allelopathy="Not simulated: no chemical suppression or plant killing.",
|
||||
@@ -75,10 +75,10 @@ return function(hearth, api)
|
||||
elseif tag=="browse" or tag=="pollinator" or tag=="nursery" then status="simulated"
|
||||
elseif tag=="ectomycorrhiza" then
|
||||
if r.group=="fungi" then
|
||||
status,game="simulated","Active compatible nearby fungus permits seedling growth at 16200 instead of 21600 active seconds; successful growth debits at most 1 existing soil nutrient once, regardless of fungus count. No nutrient creation or carbon/water ledger."
|
||||
status,game="simulated","Active compatible nearby fungus permits a finite nutrient exchange: successful calendar-eligible seedling growth debits at most 1 existing soil nutrient once, regardless of fungus count. No accelerated growth, nutrient creation or carbon/water ledger; lifecycle age thresholds are unchanged."
|
||||
else game="Not simulated as a tree aura: only an active compatible nearby fungus can enable the finite seedling nutrient debit." end
|
||||
elseif tag=="saprotroph" then
|
||||
status,game="simulated","On suitable natural deadwood/litter only: advance its finite decay age by at most 60 seconds per evaluation, capped at 7200/1800. Only substrate decay pays soil nutrients once; the fungus pays none. Soil-growing saprotrophs receive no special nutrient effect."
|
||||
status,game="simulated","On suitable natural deadwood/litter only: add at most 60 seconds per evaluation to its finite decay bonus, capped at one mean real year for deadwood or 90 real days for litter. Only substrate decay pays its remaining soil nutrients once; the fungus pays none. Soil-growing saprotrophs receive no special nutrient effect."
|
||||
elseif tag=="engineer" then
|
||||
if r.id=="sphagnum" then
|
||||
status,game="simulated","Local land habitat estimate: +0.10 moisture and -0.20 pH per mat, at most 2 mats; moisture clamped 0..1, pH 3.5..8.5. No terrain, water or persistent pH writes."
|
||||
@@ -91,6 +91,11 @@ return function(hearth, api)
|
||||
if r.group=="trees" then
|
||||
add("canopy","simulated","Trunks/crowns reduce local habitat light and contribute moisture through existing canopy sampling; pine/spruce retain full Winter influence, deciduous foliage retains 15%. Not a hydrological solver.")
|
||||
end
|
||||
local profile=hearth.lifecycle and hearth.lifecycle.plant(r.id)
|
||||
add("lifecycle","simulated", "Calendar ages use real elapsed days, including unloaded time; reconciliation is bounded and loaded-only, with no missed-event replay. "
|
||||
.. (profile and "Profile: "..profile.confidence..". Each timing field is separately source_reported, policy_fallback or UNKNOWN; an identity URL is not timing evidence."
|
||||
or "Lifecycle profile UNKNOWN in this runtime; no invented maturity or lifespan.")
|
||||
.. " Lifespan endpoints never authorize automatic death; fungal/lichen unknown maturity is not propagation permission.")
|
||||
if r.toxic then add("toxicity","descriptive","Not simulated as an attack: this warning never grants a poison aura or human food permission.") end
|
||||
return out
|
||||
end
|
||||
|
||||
+336
-43
@@ -1,4 +1,4 @@
|
||||
-- Original loaded-only succession. No ABMs, map emergence, timers or world registry.
|
||||
-- Calendar age with bounded loaded reconciliation. No emergence or event replay.
|
||||
return function(hearth, api)
|
||||
local S = {BUDGET = 8, RADIUS = 12, INTERVAL = 1, MAX_GAP = 1200, CACHE_LIMIT = 8192,
|
||||
MAX_TREE_CELLS=810, MAX_WATER_DEPTH=6}
|
||||
@@ -9,6 +9,16 @@ return function(hearth, api)
|
||||
local trees = {tree_seedling=true, young_tree=true, mature_tree=true}
|
||||
local diversity = hearth.diversity
|
||||
local botany = hearth.botany
|
||||
local lifecycle = hearth.lifecycle
|
||||
local DAY, YEAR = 86400, 365.2425*86400
|
||||
local DECAY = {dung=600,litter=90*DAY,deadwood=YEAR,fungi=2400}
|
||||
-- One synchronous, separately paid cache handoff, never a saved animal seed.
|
||||
local cache_nut
|
||||
local function resource_now()
|
||||
local t=api.now and api.now() or os.time()
|
||||
if type(t)~="number" or t~=t or t<0 or t>9007199254740991-YEAR then return nil end
|
||||
return math.floor(t)
|
||||
end
|
||||
local effects = hearth.species_effects
|
||||
-- Content may inject the factory before succession. Standalone integrations
|
||||
-- without it keep their old API; the engine loads only this fixed sibling.
|
||||
@@ -56,6 +66,98 @@ return function(hearth, api)
|
||||
if not n or n ~= n or n < 0 or n == math.huge then return 0 end
|
||||
return math.min(maximum or 1000000000000, n)
|
||||
end
|
||||
local function valid_clock(n)
|
||||
return type(n)=="number" and n==n and n>=0 and n<=9007199254740991
|
||||
end
|
||||
local function stored(m,f,default,maximum)
|
||||
local s=m:get_string(f)
|
||||
if s=="" then return default end
|
||||
local n=tonumber(s)
|
||||
if not valid_clock(n) or n>(maximum or 9007199254740991) then return nil end
|
||||
return n
|
||||
end
|
||||
local function profile_for(k,p)
|
||||
local id=botany and botany.kind(k) or (diversity and diversity.tree_info(k))
|
||||
local profile=id and lifecycle and lifecycle.plant(id)
|
||||
if not profile and (trees[k] or (k=="log" and api.get_meta(p):get_string("succ_worldgen_stage")=="mature")) and not id then
|
||||
-- Unidentified legacy tree: explicit conservative family policy, not
|
||||
-- a silently assigned birch identity or an accelerated timber crop.
|
||||
profile={id="generic_tree",kind="plant",confidence="policy_fallback",
|
||||
juvenile_days={min=365.2425,max=365.2425},maturity_days={min=3652.425,max=3652.425},
|
||||
lifespan_days=false,germination_days=false}
|
||||
end
|
||||
if not profile then return nil end
|
||||
local seed=math.abs(p.x*73856093.0+p.y*19349663.0+p.z*83492791.0)%2147483647
|
||||
return profile,seed
|
||||
end
|
||||
-- Persist one root anchor, never reinterpret stage-local legacy active seconds.
|
||||
-- Inferred establishment age is separate from the observed adoption epoch.
|
||||
local function calendar(p,k,now,origin)
|
||||
if not valid_clock(now) then return nil end
|
||||
local profile,seed=profile_for(k,p)
|
||||
if not profile then return nil end
|
||||
local m=api.get_meta(p)
|
||||
local version=m:get_string("succ_lifecycle_version")
|
||||
if version=="" then
|
||||
-- A partly erased record is corruption, not permission to reroll age.
|
||||
if m:get_string("succ_birth_at")~="" or m:get_string("succ_lifecycle_profile")~="" then return nil end
|
||||
local offset=0
|
||||
origin=origin or m:get_string("succ_pending_origin")
|
||||
if origin=="" or not origin then origin="legacy_established" end
|
||||
local stage=diversity and select(2,diversity.tree_info(k))
|
||||
stage=stage or ({tree_seedling="seedling",young_tree="young",mature_tree="mature"})[k]
|
||||
stage=stage or (m:get_string("succ_worldgen_stage")=="mature" and "mature")
|
||||
if origin~="natural_seed" then
|
||||
local threshold
|
||||
if stage=="seedling" then threshold=profile.germination_days
|
||||
elseif stage=="young" then threshold=profile.juvenile_days
|
||||
else threshold=profile.maturity_days end
|
||||
offset=threshold and threshold.max*DAY or 0
|
||||
end
|
||||
m:set_string("succ_birth_at",tostring(now))
|
||||
m:set_string("succ_initial_age_seconds",tostring(offset))
|
||||
m:set_string("succ_lifecycle_seed",tostring(seed))
|
||||
m:set_string("succ_lifecycle_origin",origin)
|
||||
m:set_string("succ_lifecycle_profile",profile.id)
|
||||
m:set_string("succ_lifecycle_policy","calendar_v1_conservative_max")
|
||||
m:set_string("succ_last_observed_at",tostring(now))
|
||||
m:set_string("succ_seed_epoch",tostring(now))
|
||||
m:set_string("succ_lifecycle_version","1")
|
||||
m:set_string("succ_pending_origin","")
|
||||
end
|
||||
if m:get_string("succ_lifecycle_version")~="1" or m:get_string("succ_lifecycle_profile")~=profile.id then return nil end
|
||||
local born=tonumber(m:get_string("succ_birth_at"))
|
||||
local initial=tonumber(m:get_string("succ_initial_age_seconds"))
|
||||
local previous=tonumber(m:get_string("succ_last_observed_at"))
|
||||
local stored_seed=tonumber(m:get_string("succ_lifecycle_seed"))
|
||||
local epoch=tonumber(m:get_string("succ_seed_epoch"))
|
||||
if not valid_clock(born) or not valid_clock(initial) or not valid_clock(previous) or not valid_clock(epoch)
|
||||
or not stored_seed or stored_seed<0 or stored_seed>2147483646 or stored_seed%1~=0
|
||||
or previous<born or now<previous or now<born then return nil end
|
||||
local age=initial+(now-born)
|
||||
if not valid_clock(age) then return nil end
|
||||
local phase
|
||||
if lifecycle then phase=lifecycle.phase(profile,0,age)
|
||||
else
|
||||
phase={phase=age<profile.juvenile_days.max*DAY and "seedling"
|
||||
or (age<profile.maturity_days.max*DAY and "juvenile" or "mature"),age_seconds=age,death_due=false}
|
||||
end
|
||||
if not phase then return nil end
|
||||
if previous~=now then m:set_string("succ_last_observed_at",tostring(now)) end
|
||||
return phase,profile
|
||||
end
|
||||
local function flowering_ready(q,r,now)
|
||||
local m=api.get_meta(q)
|
||||
if m:get_string("succ_lifecycle_version")=="" then return true end -- established, not yet adopted
|
||||
if m:get_string("succ_lifecycle_version")~="1" or not lifecycle then return false end
|
||||
local born=tonumber(m:get_string("succ_birth_at"))
|
||||
local initial=tonumber(m:get_string("succ_initial_age_seconds"))
|
||||
local high=tonumber(m:get_string("succ_last_observed_at"))
|
||||
if not valid_clock(born) or not valid_clock(initial) or not valid_clock(high) or now<born or now<high then return false end
|
||||
local profile=lifecycle.plant(r.id)
|
||||
local phase=profile and lifecycle.phase(profile,0,initial+now-born)
|
||||
return phase~=nil and phase.phase=="mature"
|
||||
end
|
||||
local markers = {"owner", "planter", "placed_by", "player_placed", "hearth_placed", "hearth_player_placed", "planted_at"}
|
||||
local function marked(p)
|
||||
local meta = api.get_meta(p)
|
||||
@@ -165,6 +267,7 @@ return function(hearth, api)
|
||||
h.nutrients=number(api.get_meta(at(p,0,-1,0)),"succ_nutrients",8)
|
||||
-- Leafless deciduous branches cast less shade than a summer canopy.
|
||||
local season=hearth.seasons and hearth.seasons.season() or "Summer"
|
||||
local habitat_now=os.time()
|
||||
local foliage = season == "Winter" and 0.15 or 1
|
||||
-- Fixed 5x5x5 stencil: includes river water, vegetation and overhead canopy.
|
||||
for x=-2,2 do for z=-2,2 do for y=-1,3 do
|
||||
@@ -203,10 +306,11 @@ return function(hearth, api)
|
||||
end
|
||||
if not h.host and r.group=="trees" then h.host=r.id end
|
||||
if r.group=="grasses" then h.grass=h.grass+1 end
|
||||
if botany.pollinator(k,season) and r.group~="trees" then h.flowers=h.flowers+1 end
|
||||
local blooms=botany.pollinator(k,season) and flowering_ready(q,r,habitat_now)
|
||||
if blooms and r.group~="trees" then h.flowers=h.flowers+1 end
|
||||
if r.group=="shrubs" or r.group=="ferns_mosses" or r.group=="aquatic" then h.cover=h.cover+1 end
|
||||
if botany.browse(k,season) and not component then h.edible=h.edible+1 end
|
||||
if botany.pollinator(k,season) and not component then h.pollination=h.pollination+1 end
|
||||
if blooms and not component then h.pollination=h.pollination+1 end
|
||||
if botany.active(k,season) and r.tags.nursery and not component then h.nursery=h.nursery+1 end
|
||||
if r.group=="fungi" and r.tags.ectomycorrhiza and botany.active(k,season) then h.root_fungi[#h.root_fungi+1]=r.id end
|
||||
end
|
||||
@@ -425,7 +529,25 @@ return function(hearth, api)
|
||||
if species or origin then m:set_string("succ_species",species or origin)
|
||||
elseif target~="deadwood" and target~="litter" then m:set_string("succ_species","") end
|
||||
m:set_string("succ_natural", target and "1" or "")
|
||||
m:set_string("succ_age", "0"); m:set_string("succ_stress", "0")
|
||||
local oldprofile=m:get_string("succ_lifecycle_profile")
|
||||
local nextprofile=target and profile_for(target,p)
|
||||
local same=nextprofile and oldprofile==nextprofile.id
|
||||
if not same then
|
||||
for _,field in ipairs({"succ_birth_at","succ_initial_age_seconds","succ_last_observed_at","succ_lifecycle_version",
|
||||
"succ_lifecycle_seed","succ_lifecycle_origin","succ_lifecycle_profile","succ_lifecycle_policy","succ_seed_epoch","succ_litter_epoch"}) do
|
||||
m:set_string(field,"")
|
||||
end
|
||||
m:set_string("succ_age","0")
|
||||
if nextprofile then calendar(p,target,os.time(),"natural_seed") end
|
||||
end
|
||||
m:set_string("succ_stress", "0")
|
||||
if target=="litter" or target=="deadwood" then
|
||||
m:set_string("succ_decay_at",tostring(os.time()))
|
||||
m:set_string("succ_decay_seen_at",tostring(os.time()))
|
||||
m:set_string("succ_decay_initial", "0")
|
||||
m:set_string("succ_decay_bonus", "0")
|
||||
m:set_string("succ_litter_remaining",target=="litter" and "1" or "")
|
||||
end
|
||||
if hearth.invalidate_environment then hearth.invalidate_environment(p) end
|
||||
return true
|
||||
end
|
||||
@@ -480,21 +602,44 @@ return function(hearth, api)
|
||||
if component~="" and component~=key(p) then return false end
|
||||
local dt = active(p,now)
|
||||
local m, n = api.get_meta(p), node(p)
|
||||
if dt == 0 then return false end
|
||||
local h = S.habitat(p)
|
||||
if not h.loaded then seen[key(p)]=nil; return false end
|
||||
local k = kind(n)
|
||||
local f=family(k)
|
||||
local species=diversity and diversity.tree_info(k)
|
||||
if m:get_string("succ_worldgen_stage")=="mature" then
|
||||
f="mature_tree"
|
||||
local id=m:get_string("succ_species")
|
||||
if id~="generic" then species=id end
|
||||
end
|
||||
local record=botany and botany.get(k)
|
||||
local calendar_profile=profile_for(k,p)
|
||||
local phase=calendar_profile and calendar(p,k,now)
|
||||
if calendar_profile and not phase then return false end
|
||||
-- Dead organic matter also has a calendar, independent of visit count.
|
||||
-- Legacy active seconds are retained as a small observed initial age.
|
||||
local organic=k=="litter" or k=="deadwood"
|
||||
local decay_age
|
||||
if organic then
|
||||
local born=stored(m,"succ_decay_at",now)
|
||||
local high=stored(m,"succ_decay_seen_at",now)
|
||||
local initial=stored(m,"succ_decay_initial",number(m,"succ_age",DECAY[k]),DECAY[k])
|
||||
local bonus=stored(m,"succ_decay_bonus",0,DECAY[k])
|
||||
if not born or not high or not initial or not bonus or born>now or high>now then return false end
|
||||
decay_age=initial+now-born+bonus
|
||||
m:set_string("succ_decay_at",tostring(born)); m:set_string("succ_decay_seen_at",tostring(now))
|
||||
m:set_string("succ_decay_initial",tostring(initial))
|
||||
end
|
||||
if dt==0 then return false end -- one cheap baseline, no side effects replay
|
||||
local season=hearth.seasons and hearth.seasons.season(now) or "Summer"
|
||||
local climate = hearth.climate_at and hearth.climate_at(now)
|
||||
local warm = not climate or (climate.temperature or 12) >= 3
|
||||
local decomposer = k=="dung" or k=="litter" or k=="deadwood" or k=="fungi"
|
||||
local dormant = (record and not botany.active(k,season)) or (hearth.seasons and not hearth.seasons.available(k,now))
|
||||
local age_dt = (not decomposer and (not warm or dormant)) and 0 or dt
|
||||
local age = number(m,"succ_age",1000000)+age_dt
|
||||
m:set_string("succ_age", tostring(age))
|
||||
local age = decay_age or (phase and phase.age_seconds) or number(m,"succ_age",1000000)+age_dt
|
||||
-- Keep the old stage-local counter untouched on migrated living roots.
|
||||
if not phase then m:set_string("succ_age", tostring(age)) end
|
||||
if number(m,"succ_next") > now then return false end
|
||||
-- Commit the attempt BEFORE evaluating it, including failed competition/dispersal.
|
||||
m:set_string("succ_next", tostring(now+PERIOD))
|
||||
@@ -546,24 +691,29 @@ return function(hearth, api)
|
||||
local stress_dt=botany and h.nursery>0 and dt*0.75 or dt
|
||||
local stress = harsh and number(m,"succ_stress",100000)+stress_dt or 0
|
||||
m:set_string("succ_stress",tostring(stress))
|
||||
if stress>=600 then
|
||||
if stress>=600 and not phase then
|
||||
local remains=trees[k] and "deadwood" or "litter"
|
||||
if botany and ((record and record.group=="fungi") or h.water>0 or h.substrate=="bark" or h.substrate=="deadwood" or h.substrate=="litter") then remains=nil end
|
||||
if diversity and diversity.is_rock(node(at(p,0,-1,0))) then remains=nil end
|
||||
return change(p,remains)
|
||||
end
|
||||
if decomposer then
|
||||
local lifetime = ({dung=600,litter=1800,deadwood=7200,fungi=2400})[k]
|
||||
local lifetime = DECAY[k]
|
||||
if age>=lifetime then
|
||||
local remaining=k=="litter" and stored(m,"succ_litter_remaining",1,1) or 1
|
||||
if not remaining then return false end
|
||||
local target = k=="deadwood" and "litter" or nil
|
||||
if change(p,target) then nutrients(p,k=="dung" and 4 or (k=="deadwood" and 3 or 1)); return true end
|
||||
if change(p,target) then
|
||||
if k=="litter" then m:set_string("succ_litter_remaining","0") end
|
||||
nutrients(p,k=="dung" and 4 or (k=="deadwood" and 3 or remaining)); return true
|
||||
end
|
||||
elseif k~="fungi" and h.moisture>=0.55 and h.light<=12 and age>=300 then
|
||||
disperse(p,"fungi",serial)
|
||||
end
|
||||
return false
|
||||
end
|
||||
if not warm then return false end
|
||||
if record and age>=900 and record.tags.nitrogenfixer then
|
||||
if record and age>=90*DAY and record.tags.nitrogenfixer then
|
||||
local below=at(p,0,-1,0)
|
||||
local soil=api.get_meta(below)
|
||||
local fixed=number(soil,"succ_fixed_nitrogen",2)
|
||||
@@ -572,11 +722,10 @@ return function(hearth, api)
|
||||
soil:set_string("succ_fixed_nitrogen",tostring(fixed+delta))
|
||||
end
|
||||
end
|
||||
local seed_age=21600
|
||||
local seed_age=calendar_profile and calendar_profile.juvenile_days and calendar_profile.juvenile_days.max*DAY or YEAR
|
||||
local exchange=0
|
||||
if botany and species and f=="tree_seedling" then
|
||||
for _,fungus in ipairs(h.root_fungi) do exchange=math.max(exchange,botany.root_fungi(fungus,species,fertility)) end
|
||||
if exchange>0 then seed_age=16200 end
|
||||
end
|
||||
if f=="tree_seedling" and age>=seed_age and h.canopy<1.5 then
|
||||
if change(p,species and species.."_young" or "young_tree") then
|
||||
@@ -585,13 +734,19 @@ return function(hearth, api)
|
||||
end
|
||||
return false
|
||||
end
|
||||
if f=="young_tree" and age>=86400 and h.canopy<2 then return change(p,species and species.."_mature" or "mature_tree") end
|
||||
local maturity=calendar_profile and calendar_profile.maturity_days and calendar_profile.maturity_days.max*DAY or 10*YEAR
|
||||
if f=="young_tree" and age>=maturity and h.canopy<2 then return change(p,species and species.."_mature" or "mature_tree") end
|
||||
if f=="mature_tree" then
|
||||
if age>=259200 then return change(p,"deadwood") end
|
||||
if age>=1800 and serial%10==0 then disperse(p,species and species.."_seedling" or "tree_seedling",serial) end
|
||||
if serial%5==0 then disperse(p,"litter",serial) end
|
||||
-- One present seed opportunity, never one offspring per missed year.
|
||||
if phase and phase.phase=="mature" and (not record or botany.seeding(k,season))
|
||||
and now>=number(m,"succ_seed_epoch",9007199254740991) then
|
||||
m:set_string("succ_seed_epoch",tostring(now+YEAR))
|
||||
disperse(p,species and species.."_seedling" or "tree_seedling",serial)
|
||||
end
|
||||
if now>=number(m,"succ_litter_epoch",9007199254740991) then
|
||||
m:set_string("succ_litter_epoch",tostring(now+30*DAY)); disperse(p,"litter",serial)
|
||||
end
|
||||
elseif record and not trees[k] then
|
||||
local lifetime=({shrubs=86400,grasses=21600,herbs=14400,ferns_mosses=43200,fungi=2400,aquatic=21600,lichens=86400})[record.group]
|
||||
if record.tags.saprotroph and (h.substrate=="deadwood" or h.substrate=="litter")
|
||||
and botany.suitable(k,h,season) then
|
||||
-- Advance only an existing natural substrate's finite clock;
|
||||
@@ -599,24 +754,17 @@ return function(hearth, api)
|
||||
local below=at(p,0,-1,0)
|
||||
if natural(below,node(below)) and permitted(below) then
|
||||
local wood=api.get_meta(below)
|
||||
local limit=h.substrate=="deadwood" and 7200 or 1800
|
||||
wood:set_string("succ_age",tostring(math.min(limit,number(wood,"succ_age",limit)+math.min(dt,PERIOD))))
|
||||
local limit=DECAY[h.substrate]
|
||||
local bonus=stored(wood,"succ_decay_bonus",0,limit)
|
||||
if bonus then wood:set_string("succ_decay_bonus",tostring(math.min(limit,bonus+math.min(dt,PERIOD)))) end
|
||||
end
|
||||
end
|
||||
if age>=lifetime then
|
||||
local remains="litter"
|
||||
if record.group=="fungi" or h.water>0 or h.substrate=="bark" or h.substrate=="deadwood" or h.substrate=="litter"
|
||||
or (diversity and diversity.is_rock(node(at(p,0,-1,0)))) then remains=nil end
|
||||
return change(p,remains)
|
||||
-- Unknown maturity (fungi/lichens) is not automatic propagation or
|
||||
-- death permission. A sprite persists as its root/colony observation.
|
||||
if phase and phase.phase=="mature" and botany.seeding(k,season)
|
||||
and now>=number(m,"succ_seed_epoch",9007199254740991) then
|
||||
m:set_string("succ_seed_epoch",tostring(now+30*DAY)); disperse(p,k,serial)
|
||||
end
|
||||
if age>=7200 and h.water==0 and ground(at(p,0,-1,0)) and h.canopy<1.5 and (p.x*7+p.z*11)%23==0 then
|
||||
local th={}; for field,value in pairs(h) do th[field]=value end; th.include_trees=true
|
||||
-- The same catalogue selector is the source of all ten trees;
|
||||
-- skip non-tree results without changing its deterministic order.
|
||||
local recruit=botany.choose(p,th,season)
|
||||
if recruit and trees[recruit] then return change(p,recruit) end
|
||||
end
|
||||
if age>=900 and serial%3==0 then disperse(p,k,serial) end
|
||||
elseif diversity and diversity.is_worldgen_flora(k) and not trees[k] then
|
||||
if age>=7200 and h.canopy<1.5 and (p.x*7+p.z*11)%23==0 then
|
||||
local recruit=diversity.choose_tree(p,h)
|
||||
@@ -660,25 +808,170 @@ return function(hearth, api)
|
||||
end end
|
||||
return false
|
||||
end
|
||||
function S.forage(raw)
|
||||
-- All animal resource probes are read-only until their complete preflight.
|
||||
-- No bootstrap/adoption, calendar writes, callbacks or inventory grants here.
|
||||
local function resource_site(q)
|
||||
local n=node(q)
|
||||
local below=at(q,0,-1,0)
|
||||
if not n or not node(below) or not natural(q,n) or not permitted(q) or not permitted(below) then return nil end
|
||||
local component=api.get_meta(q):get_string("succ_component")
|
||||
if component~="" and component~=key(q) then return nil end
|
||||
if not ground(below) or (diversity and not diversity.is_ground(node(below))) then return nil end
|
||||
return n,api.get_meta(q),below
|
||||
end
|
||||
local function mature_resource(q,r,time)
|
||||
local m=api.get_meta(q)
|
||||
if not lifecycle or not r then return false end
|
||||
local profile=lifecycle.plant(r.id)
|
||||
if not profile or not profile.maturity_days then return false end
|
||||
if m:get_string("succ_lifecycle_version")=="" then
|
||||
-- Same established-stand assumption as calendar adoption, without
|
||||
-- mutating a failed transaction or treating a seedling as mature.
|
||||
if m:get_string("succ_birth_at")~="" or m:get_string("succ_lifecycle_profile")~="" then return false end
|
||||
local k=kind(node(q))
|
||||
return r.group~="trees" or family(k)=="mature_tree"
|
||||
or (family(k)=="log" and m:get_string("succ_worldgen_stage")=="mature")
|
||||
end
|
||||
if m:get_string("succ_lifecycle_version")~="1" or m:get_string("succ_lifecycle_profile")~=r.id then return false end
|
||||
local born=stored(m,"succ_birth_at",nil)
|
||||
local initial=stored(m,"succ_initial_age_seconds",nil)
|
||||
local high=stored(m,"succ_last_observed_at",nil)
|
||||
if not born or not initial or not high or high<born or time<high or time<born then return false end
|
||||
local phase=lifecycle.phase(profile,0,initial+time-born)
|
||||
return phase and phase.phase=="mature" or false
|
||||
end
|
||||
local function resource_season(time)
|
||||
local c=hearth.climate_at and hearth.climate_at(time)
|
||||
return c and c.season or (hearth.seasons and hearth.seasons.season(time))
|
||||
end
|
||||
function S.pollinate(raw)
|
||||
cache_nut=nil
|
||||
local p,time=position(raw),resource_now()
|
||||
if not p or not time or not botany then return false end
|
||||
local n,m=resource_site(p)
|
||||
local r=n and botany.get(n)
|
||||
local season=resource_season(time)
|
||||
if not r or not botany.active(n,season) or not botany.pollinator(n,season) or not mature_resource(p,r,time) then return false end
|
||||
local ready=stored(m,"succ_nectar_ready_at",0)
|
||||
local pollen=stored(m,"succ_pollinated_until",0)
|
||||
if not ready or not pollen or ready>time or pollen>time+DAY then return false end
|
||||
m:set_string("succ_nectar_ready_at",tostring(time+3600))
|
||||
-- Boolean shared credit: not an accumulating yield counter or seeds.
|
||||
if pollen<=time then m:set_string("succ_pollinated_until",tostring(time+DAY)) end
|
||||
return true
|
||||
end
|
||||
function S.frugivore(raw,species_id)
|
||||
cache_nut=nil
|
||||
local p,time=position(raw),resource_now()
|
||||
if not p or not time or not botany then return false end
|
||||
local n,m=resource_site(p)
|
||||
local r=n and botany.get(n)
|
||||
local season=resource_season(time)
|
||||
if not r or (species_id~=nil and species_id~=r.id) or r.group~="shrubs"
|
||||
or not botany.active(n,season) or not botany.edible(n,season) or not mature_resource(p,r,time) then return false end
|
||||
local ready=stored(m,"botany_ready_at",0)
|
||||
if not ready or ready>time then return false end
|
||||
-- Eat one unit, discard the rest of the existing two-fruit batch.
|
||||
-- Share the human lock, never create a second animal-only reserve.
|
||||
m:set_string("botany_ready_at",tostring(time+YEAR))
|
||||
return true
|
||||
end
|
||||
function S.granivore(raw,species_id)
|
||||
cache_nut=nil
|
||||
local p,time=position(raw),resource_now()
|
||||
if not p or not time or not botany then return false end
|
||||
local n,m=resource_site(p)
|
||||
local r=n and botany.get(n)
|
||||
local season=resource_season(time)
|
||||
if not r or (species_id~=nil and species_id~=r.id) or (r.id~="oak" and r.id~="hazel")
|
||||
or season~="Autumn" or not botany.seeding(n,season) or not mature_resource(p,r,time) then return false end
|
||||
local rootkind=family(kind(n))
|
||||
if rootkind~="mature_tree" and not (rootkind=="log" and m:get_string("succ_worldgen_stage")=="mature") then return false end
|
||||
local ok,date=pcall(os.date,"!*t",time)
|
||||
local year=ok and type(date)=="table" and date.year or nil
|
||||
if not valid_clock(year) then return false end
|
||||
local previous=stored(m,"succ_nuts_year",0)
|
||||
local remaining=stored(m,"succ_nuts_remaining",2,2)
|
||||
if not previous or not remaining or previous%1~=0 or remaining%1~=0 or previous>year then return false end
|
||||
if previous<year then remaining=2 end -- current crop only, no missed-year replay
|
||||
if remaining<1 then return false end
|
||||
local cache=remaining>=2
|
||||
m:set_string("succ_nuts_year",tostring(year))
|
||||
m:set_string("succ_nuts_remaining",tostring(remaining-(cache and 2 or 1)))
|
||||
if cache then cache_nut={id=r.id,time=time,source=p}; return true,r.id end
|
||||
return true
|
||||
end
|
||||
function S.disperse_seed(raw,species_id)
|
||||
local paid=cache_nut; cache_nut=nil -- every attempt spends it, even failure
|
||||
local p,time=position(raw),resource_now()
|
||||
if not paid or not p or not time or paid.time~=time or paid.id~=species_id then return false end
|
||||
-- Immediate local handoff only. Recheck the donor without replenishing it.
|
||||
if math.abs(p.x-paid.source.x)>2 or p.y~=paid.source.y or math.abs(p.z-paid.source.z)>2 then return false end
|
||||
local donor=resource_site(paid.source)
|
||||
if not donor or botany.kind(donor)~=species_id then return false end
|
||||
local n=node(p); local below=at(p,0,-1,0)
|
||||
if not n or n.name~="air" or not node(below) or not ground(below)
|
||||
or not permitted(p) or not permitted(below) then return false end
|
||||
local m=api.get_meta(p)
|
||||
local ready=stored(m,"succ_next",0)
|
||||
if not ready or ready>time then return false end
|
||||
if not change(p,species_id.."_seedling") then return false end
|
||||
m:set_string("succ_next",tostring(time+DAY))
|
||||
return true
|
||||
end
|
||||
function S.detritivore(raw)
|
||||
cache_nut=nil
|
||||
local p,time=position(raw),resource_now()
|
||||
if not p or not time then return false end
|
||||
local n,m,below=resource_site(p)
|
||||
if not n or kind(n)~="litter" then return false end
|
||||
local remaining=stored(m,"succ_litter_remaining",1,1)
|
||||
local fertility=stored(api.get_meta(below),"succ_nutrients",0,8)
|
||||
if not remaining or remaining<0.099999 or not fertility or fertility>7.9 then return false end
|
||||
local last=remaining<0.100001
|
||||
-- Final removal must preflight any dependent fungus BEFORE any debit.
|
||||
if last and not change(p,nil) then return false end
|
||||
m:set_string("succ_litter_remaining",tostring(last and 0 or remaining-0.1))
|
||||
api.get_meta(below):set_string("succ_nutrients",tostring(fertility+0.1))
|
||||
return true
|
||||
end
|
||||
function S.forage(raw,mode)
|
||||
cache_nut=nil
|
||||
local p = position(raw)
|
||||
if not p or not node(p) or not permitted(p) then return false end
|
||||
local time=resource_now()
|
||||
if not p or not time or not node(p) or not permitted(p) then return false end
|
||||
return nearby(p,function(q)
|
||||
local n = node(q)
|
||||
local n,m = resource_site(q)
|
||||
local k=kind(n)
|
||||
if not natural(q,n) then return false end
|
||||
if not n then return false end
|
||||
if botany and botany.kind(k) then
|
||||
if not botany.browse(k,hearth.seasons and hearth.seasons.season() or "Summer")
|
||||
or (trees[k] and family(k)~="tree_seedling") then return false end
|
||||
local season=hearth.seasons and hearth.seasons.season() or "Summer"
|
||||
local ordinary=botany.browse(k,season)
|
||||
local seasonal=false
|
||||
if (mode=="seasonal_browse" or mode=="winter_browse")
|
||||
and (season=="Autumn" or season=="Winter") then
|
||||
local profile=botany.get(k)
|
||||
seasonal=profile and profile.tags.browse and not profile.toxic
|
||||
and (profile.group=="trees" or profile.group=="shrubs" or profile.group=="grasses")
|
||||
end
|
||||
local stage=family(k)
|
||||
local tree_identity=diversity and diversity.tree_info(k)
|
||||
local living_tree=tree_identity~=nil or trees[k]
|
||||
if (not ordinary and not seasonal) or (living_tree and stage~="tree_seedling") then return false end
|
||||
elseif diversity then if not diversity.edible(k) then return false end
|
||||
elseif k~="meadow_grass" and k~="fern" then return false end
|
||||
if hearth.seasons and not hearth.seasons.available(k) then return false end
|
||||
if not change(q,nil) then return false end
|
||||
api.get_meta(q):set_string("succ_next",tostring(os.time()+300))
|
||||
if mode~="seasonal_browse" and mode~="winter_browse"
|
||||
and hearth.seasons and not hearth.seasons.available(k) then return false end
|
||||
local ready=stored(m,"succ_browse_ready_at",0)
|
||||
if not ready or ready>time then return false end
|
||||
-- One partial canopy ration, not destruction of the whole perennial.
|
||||
-- Seven real days is a conservative game fallback, not measured growth.
|
||||
m:set_string("succ_browse_ready_at",tostring(time+7*DAY))
|
||||
return true
|
||||
end)
|
||||
end
|
||||
function S.deposit(raw)
|
||||
cache_nut=nil
|
||||
local p = position(raw)
|
||||
if not p or not node(p) or not permitted(p) then return false end
|
||||
return nearby(p,function(q)
|
||||
@@ -743,7 +1036,7 @@ return function(hearth, api)
|
||||
if hearth.seasons then
|
||||
for _, name in ipairs(hearth.seasons.names()) do reload_nodes[#reload_nodes+1]=name end
|
||||
end
|
||||
api.register_lbm({name="hearth:succession_loaded", label="Pause natural succession across unloading",
|
||||
api.register_lbm({name="hearth:succession_loaded", label="Reconcile calendar roots within the loaded work budget",
|
||||
nodenames=reload_nodes, run_at_every_load=true,
|
||||
action=function(p)
|
||||
seen[key(p)]=nil
|
||||
|
||||
+463
-83
File diff suppressed because one or more lines are too long
+924
-223
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user