302 lines
12 KiB
Lua
Executable File
302 lines
12 KiB
Lua
Executable File
-- Loaded-only fauna: bounded work, no drops, combat, restores or catch-up.
|
|
return function(hearth, api)
|
|
local GLOBAL, LOCAL, PLAYER, RADIUS, BUDGET = 32, 6, 6, 48, 12
|
|
local kinds = {"hare", "deer", "fox"}
|
|
local species = {
|
|
hare={cap=16,radius=0.23,height=0.85,body=0.48,length=0.32},
|
|
deer={cap=10,radius=0.32,height=1.52,body=1.12,length=0.49},
|
|
fox={cap=6,radius=0.23,height=0.80,body=0.57,length=0.43},
|
|
}
|
|
local active, counts, total, serial = {}, {hare=0,deer=0,fox=0}, 0, 0
|
|
local clock, credit, recruitment, player_cursor, recruit_kind, cursor = 0, 0, 0, 0, 0, 0
|
|
local actions, births, meals, grazed, dung = 0, 0, 0, 0, 0
|
|
local directions={{1,0},{1,1},{0,1},{-1,1},{-1,0},{-1,-1},{0,-1},{1,-1}}
|
|
local offsets={{8,0},{-8,0},{0,8},{0,-8},{16,8},{-16,-8},{24,0},{0,24}}
|
|
local function delta(dt)
|
|
if type(dt)~="number" or dt~=dt or dt<0 or dt==math.huge then return 0 end
|
|
return math.min(dt,1)
|
|
end
|
|
local function valid(p)
|
|
if type(p)~="table" then return false end
|
|
for _, axis in ipairs({"x","y","z"}) do
|
|
local n=p[axis]
|
|
if type(n)~="number" or n~=n or math.abs(n)>30900 then return false end
|
|
end
|
|
return true
|
|
end
|
|
local function at(p,x,y,z) return {x=p.x+x,y=p.y+y,z=p.z+z} end
|
|
local function cell(p) return {x=math.floor(p.x+0.5),y=math.floor(p.y+0.5),z=math.floor(p.z+0.5)} end
|
|
local function distance(a,b) return (a.x-b.x)^2+(a.y-b.y)^2+(a.z-b.z)^2 end
|
|
local function permitted(p)
|
|
if hearth.claim_owner_at then
|
|
local owner, ambiguous=hearth.claim_owner_at(p)
|
|
if owner or ambiguous then return false end
|
|
end
|
|
local protect=hearth.external_protected or api.is_protected
|
|
return type(protect)=="function" and not protect(p,"")
|
|
end
|
|
local function node(p)
|
|
if not valid(p) or not permitted(p) then return nil end
|
|
local n=api.get_node_or_nil(p)
|
|
if n and n.name~="ignore" then return n end
|
|
end
|
|
local function natural(p,n)
|
|
if not n or api.get_meta(p):get_string("succ_natural")~="1" then return false end
|
|
local k=hearth.seasons and hearth.seasons.kind(n) or n.name:match("^hearth:(.+)$")
|
|
if hearth.seasons and not hearth.seasons.available(k) then return false end
|
|
if hearth.diversity then return hearth.diversity.edible(k) end
|
|
return k=="meadow_grass" or k=="fern"
|
|
end
|
|
local function clear(p)
|
|
local n=node(p)
|
|
return n and (n.name=="air" or natural(p,n) or n.name=="hearth:dung")
|
|
end
|
|
local function safe(p,kind)
|
|
if not valid(p) or not permitted(p) then return false end
|
|
local s=species[kind]
|
|
-- A yaw-independent square body envelope; only flat natural terrain.
|
|
local r=math.max(s.radius,s.length)
|
|
for _, x in ipairs({-r,0,r}) do for _, z in ipairs({-r,0,r}) do
|
|
local n=node(cell(at(p,x,-0.05,z)))
|
|
if not n or not (n.name=="hearth:grass" or n.name=="hearth:soil"
|
|
or (hearth.diversity and hearth.diversity.is_ground(n))) then return false end
|
|
for _, y in ipairs({0.05,1.1,s.height,2.1}) do
|
|
if not clear(cell(at(p,x,y,z))) then return false end
|
|
end
|
|
end end
|
|
return true
|
|
end
|
|
local function path(a,b,kind)
|
|
if not valid(a) or not valid(b) or distance(a,b)>2.26 then return false end
|
|
for i=0,6 do
|
|
local t=i/6
|
|
if not safe({x=a.x+(b.x-a.x)*t,y=a.y+(b.y-a.y)*t,z=a.z+(b.z-a.z)*t},kind) then return false end
|
|
end
|
|
return true
|
|
end
|
|
local function habitat(p)
|
|
local s=hearth.succession
|
|
if not s or not s.habitat then return false end
|
|
local c=cell(at(p,0,0.1,0))
|
|
local h=s.habitat(c)
|
|
if not h or h.loaded~=true or ((h.grass or 0)<=0 and (h.cover or 0)<=0 and (h.edible or 0)<=0) then return false end
|
|
-- cover also counts trees: a forest without edible meadow/fern is not
|
|
-- a recruitment or breeding site. Read at most nine loaded cells.
|
|
for x=-1,1 do for z=-1,1 do
|
|
local q=at(c,x,0,z)
|
|
if natural(q,node(q)) then return true end
|
|
end end
|
|
return false
|
|
end
|
|
local function nearby(p,r,kind,exclude)
|
|
local n=0
|
|
for e in pairs(active) do
|
|
local q=e.object:get_pos()
|
|
if e~=exclude and valid(q) and (not kind or e.kind==kind) and distance(p,q)<=r*r then n=n+1 end
|
|
end
|
|
return n
|
|
end
|
|
local function room(p,kind)
|
|
if total>=GLOBAL or counts[kind]>=species[kind].cap or nearby(p,16)>=LOCAL then return false end
|
|
local close=false
|
|
for _, player in ipairs(api.get_connected_players()) do
|
|
local q=player:get_pos()
|
|
if valid(q) and distance(p,q)<=RADIUS*RADIUS then
|
|
close=true
|
|
if nearby(q,RADIUS)>=PLAYER then return false end
|
|
end
|
|
end
|
|
return close
|
|
end
|
|
local function forget(e)
|
|
if active[e] then active[e]=nil; total=total-1; counts[e.kind]=counts[e.kind]-1 end
|
|
end
|
|
local function remove(e)
|
|
forget(e) -- release before remove/on_deactivate: each prey counts once
|
|
e.object:remove()
|
|
end
|
|
local function spawn(p,kind)
|
|
if not safe(p,kind) or not habitat(p) or not room(p,kind) then return false end
|
|
local obj=api.add_entity(p,"hearth:"..kind)
|
|
local e=obj and obj:get_luaentity()
|
|
return e and active[e] or false
|
|
end
|
|
local function callback_site(e)
|
|
local p=e.object:get_pos()
|
|
if not safe(p,e.kind) then return nil end
|
|
local c=cell(at(p,0,0.1,0))
|
|
-- forage/deposit may mutate ANY cell of their 3x3 stencil.
|
|
for x=-1,1 do for z=-1,1 do
|
|
if not safe({x=c.x+x,y=c.y-0.5,z=c.z+z},e.kind) then return nil end
|
|
end end
|
|
return c
|
|
end
|
|
local function move(e,dx,dz)
|
|
local a=e.object:get_pos()
|
|
local len=math.sqrt(dx*dx+dz*dz)
|
|
if len<0.001 then return false end
|
|
local stride=e.kind=="fox" and 1.2 or 0.9
|
|
local b=at(a,dx/len*stride,0,dz/len*stride)
|
|
if not path(a,b,e.kind) then return false end
|
|
if nearby(b,16,nil,e)>=LOCAL then return false end
|
|
for _, player in ipairs(api.get_connected_players()) do
|
|
local p=player:get_pos()
|
|
if valid(p) and distance(b,p)<=RADIUS*RADIUS and nearby(p,RADIUS,nil,e)>=PLAYER then return false end
|
|
end
|
|
e.object:set_velocity({x=0,y=0,z=0})
|
|
e.object:set_pos(b) -- short swept ground stride; gravity remains enabled
|
|
local atan2=math.atan2 or function(y,x) return math.atan(y,x) end
|
|
e.object:set_yaw(atan2(-dx,dz)) -- authored forward +Z
|
|
return true
|
|
end
|
|
local function threat(e,p)
|
|
local closest,best=nil,64
|
|
for _, player in ipairs(api.get_connected_players()) do
|
|
local q=player:get_pos()
|
|
if valid(q) and distance(p,q)<best then closest,best=q,distance(p,q) end
|
|
end
|
|
if e.kind~="fox" then
|
|
for other in pairs(active) do
|
|
local q=other.object:get_pos()
|
|
if other.kind=="fox" and valid(q) and distance(p,q)<best then closest,best=q,distance(p,q) end
|
|
end
|
|
end
|
|
return closest
|
|
end
|
|
local function breed(e,p)
|
|
local needed=e.kind=="fox" and 2 or 3
|
|
if e.age<60 or clock<e.breed_at or e.food<needed or e.hunger>35 or not habitat(p) then return false end
|
|
for mate in pairs(active) do
|
|
local q=mate.object:get_pos()
|
|
if mate~=e and mate.kind==e.kind and mate.age>=60 and mate.food>=needed
|
|
and mate.hunger<=35 and clock>=mate.breed_at and valid(q) and path(p,q,e.kind) then
|
|
for _, d in ipairs(directions) do
|
|
local b=at(p,d[1]*0.8,0,d[2]*0.8)
|
|
if path(p,b,e.kind) and spawn(b,e.kind) then
|
|
e.food=e.food-needed; mate.food=mate.food-needed
|
|
e.breed_at=clock+90; mate.breed_at=clock+90
|
|
births=births+1; return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
local function think(e)
|
|
local p=e.object:get_pos()
|
|
if not safe(p,e.kind) then remove(e); return end
|
|
local danger=threat(e,p)
|
|
if danger then
|
|
if not move(e,p.x-danger.x,p.z-danger.z) then move(e,-(p.z-danger.z),p.x-danger.x) end
|
|
return
|
|
end
|
|
local site=callback_site(e)
|
|
if e.dung_at and clock>=e.dung_at and site then
|
|
local s=hearth.succession
|
|
if s and s.deposit and s.deposit(site) then dung=dung+1 end
|
|
e.dung_at=nil -- one attempt per meal, never a growing queue
|
|
return
|
|
end
|
|
if breed(e,p) then return end
|
|
if e.kind=="fox" then
|
|
local prey,best=nil,144
|
|
for other in pairs(active) do
|
|
local q=other.object:get_pos()
|
|
if other.kind=="hare" and valid(q) and distance(p,q)<best and safe(q,"hare") then prey,best=other,distance(p,q) end
|
|
end
|
|
if prey and e.hunger>=15 then
|
|
local q=prey.object:get_pos()
|
|
if best<=1.44 and clock>=e.feed_at and path(p,q,"fox") and safe(q,"hare") then
|
|
remove(prey); meals=meals+1; e.food=math.min(6,e.food+1)
|
|
e.hunger=math.max(0,e.hunger-35); e.feed_at=clock+20; e.dung_at=clock+8
|
|
else move(e,q.x-p.x,q.z-p.z) end
|
|
return
|
|
end
|
|
elseif e.hunger>=15 and clock>=e.feed_at and site and habitat(p) then
|
|
local edible=false
|
|
for x=-1,1 do for z=-1,1 do
|
|
local q=at(site,x,0,z)
|
|
if natural(q,node(q)) then edible=true end
|
|
end end
|
|
local s=hearth.succession
|
|
if edible and s and s.forage and s.forage(site) then
|
|
e.food=math.min(6,e.food+1); e.hunger=math.max(0,e.hunger-30)
|
|
e.feed_at=clock+12; e.dung_at=clock+8; grazed=grazed+1; return
|
|
end
|
|
end
|
|
for i=1,3 do
|
|
local d=directions[(e.id+e.turn+i)%#directions+1]
|
|
local q=at(p,d[1]*0.9,0,d[2]*0.9)
|
|
if habitat(q) and move(e,d[1],d[2]) then e.turn=e.turn+1; return end
|
|
end
|
|
e.turn=e.turn+1
|
|
end
|
|
for _, kind in ipairs(kinds) do
|
|
local s=species[kind]
|
|
api.register_entity("hearth:"..kind, {
|
|
initial_properties={physical=true,collide_with_objects=true,
|
|
collisionbox={-s.radius,0,-s.length,s.radius,s.body,s.length},
|
|
visual="mesh",mesh="hearth_"..kind..".obj",textures={"hearth_"..kind..".png"},
|
|
visual_size={x=1,y=1},static_save=false,pointable=false,makes_footstep_sound=false},
|
|
on_activate=function(self,staticdata,dtime_s)
|
|
if active[self] then return end
|
|
local p=self.object:get_pos()
|
|
-- add_entity may activate synchronously; enforce caps centrally here.
|
|
if (staticdata and staticdata~="") or (dtime_s and dtime_s~=0)
|
|
or not safe(p,kind) or not habitat(p) or not room(p,kind) then self.object:remove(); return end
|
|
serial=serial+1; self.id=serial; self.kind=kind; self.age=0; self.turn=0
|
|
self.hunger=20; self.food=0; self.feed_at=clock+8; self.breed_at=clock+60
|
|
self.next_ai=clock+2; active[self]=true; total=total+1; counts[kind]=counts[kind]+1
|
|
self.object:set_armor_groups({immortal=1})
|
|
self.object:set_acceleration({x=0,y=-9.81,z=0})
|
|
self.object:set_velocity({x=0,y=0,z=0})
|
|
end,
|
|
on_step=function(self,dt)
|
|
if not active[self] then return end
|
|
local t=delta(dt)
|
|
self.age=self.age+t; self.hunger=math.min(100,self.hunger+t*0.6)
|
|
if self.age>=900 or self.hunger>=100 then remove(self); return end
|
|
local p=self.object:get_pos()
|
|
if not valid(p) or not permitted(p) or not node(cell(p)) then remove(self) end
|
|
end,
|
|
on_punch=function() return true end,
|
|
on_deactivate=forget,
|
|
})
|
|
end
|
|
api.register_globalstep(function(dt)
|
|
local t=delta(dt)
|
|
clock=clock+t; credit=math.min(BUDGET,credit+t*BUDGET); recruitment=recruitment+t
|
|
local list={}
|
|
for e in pairs(active) do list[#list+1]=e end
|
|
table.sort(list,function(a,b) return a.id<b.id end)
|
|
for _=1,#list do
|
|
if credit<1 then break end
|
|
cursor=cursor%#list+1
|
|
local e=list[cursor]
|
|
if active[e] and clock>=e.next_ai then
|
|
credit=credit-1; actions=actions+1; e.next_ai=clock+2; think(e)
|
|
end
|
|
end
|
|
if recruitment<30 or credit<1 then return end
|
|
recruitment=0; credit=credit-1; actions=actions+1
|
|
local players=api.get_connected_players()
|
|
if #players==0 then return end
|
|
player_cursor=player_cursor%#players+1; recruit_kind=recruit_kind%#kinds+1
|
|
local center=players[player_cursor]:get_pos()
|
|
if not valid(center) then return end
|
|
local kind=kinds[recruit_kind]
|
|
if kind=="fox" and nearby(center,RADIUS,"hare")<2 then return end
|
|
for _, d in ipairs(offsets) do for y=2,-3,-1 do
|
|
local p={x=math.floor(center.x+d[1]+0.5),y=math.floor(center.y+0.5)+y-0.5,z=math.floor(center.z+d[2]+0.5)}
|
|
if spawn(p,kind) then return end
|
|
end end
|
|
end)
|
|
return {species="hearth:hare",global_cap=GLOBAL,player_cap=PLAYER,local_cap=LOCAL,ai_budget=BUDGET,
|
|
status=function()
|
|
return {total=total,hare=counts.hare,deer=counts.deer,fox=counts.fox,
|
|
global_cap=GLOBAL,player_cap=PLAYER,local_cap=LOCAL,
|
|
actions=actions,births=births,meals=meals,grazed=grazed,dung=dung}
|
|
end}
|
|
end
|