mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge pull request #9713 from Hiuoy/feat/8622-mod-goals
🤖 Agent: with no mods the new hook costs one HOOKS.get() and a walk of an empty mod list per mob created, not per tick, and it runs before the float check, so a mod's goals::Float floats the navigation as FloatGoal does. The fragment stamps cleanly against main (test_stamp_native_hooks passes in pr_ready's Python tests). Pushed 9861c8352: living_mod_types now uses a temp folder per call, since parallel tests were deleting each other's folder. Merged with main, pr_ready --quick passes on Linux (4331 tests, launcher included).
This commit is contained in:
12 files changed
+201
-5
No files matched your search
@@ -155,6 +155,9 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
living_jump: |entity, kind| {
|
||||
crate::mod_api::dispatch_living_jump(crate::mod_api::mods(), entity, kind);
|
||||
},
|
||||
register_goals: |mob| {
|
||||
crate::mod_api::dispatch_register_goals(crate::mod_api::mods(), mob);
|
||||
},
|
||||
ingredient: |kind, data, items| {
|
||||
crate::mod_api::dispatch_ingredient(crate::mod_api::mods(), kind, data, items);
|
||||
},
|
||||
|
||||
@@ -455,8 +455,13 @@ pub(super) fn living_mod_types()
|
||||
"ruby:settler":{{{villager},"attributes":{{"minecraft:max_health":20.0,"minecraft:movement_speed":0.5}},"template":"minecraft:villager"}},
|
||||
"ruby:bolt":{{"width":0.25,"height":0.25,"eye_height":0.0,"category":"misc","fire_immune":false,"template":"minecraft:snowball"}}}}"#
|
||||
);
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-living-mod-types-{}", std::process::id()));
|
||||
// A folder per call: tests run in parallel in one process, and each removes its own.
|
||||
static CALLS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
let call = CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-living-mod-types-{}-{call}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::write(root.join("entity-types.json"), table).unwrap();
|
||||
let types = horizon_client::mobs::MobTypes::load_for_server(&root).unwrap();
|
||||
@@ -610,4 +615,90 @@ mod tests {
|
||||
assert!(not_built(&dir.join("missing"), "", &[]).is_empty());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
/// A trait mod's `register_goals` (`Mob.registerGoals`) gets every new
|
||||
/// mob: the goals of its type replace its template's, its `Float` goal
|
||||
/// floats the navigation as FloatGoal's constructor does, and the
|
||||
/// template's other types keep their goals. The only test here that
|
||||
/// installs the client library's hooks.
|
||||
#[test]
|
||||
fn a_mods_register_goals_replaces_its_types_goals() {
|
||||
use horizon_client::mobs::{
|
||||
Mob,
|
||||
goals::{self, Cx, Goal},
|
||||
};
|
||||
use horizon_client::mod_api::{Hooks, RegisterGoalsEvent, dispatch_register_goals};
|
||||
use horizon_client::native_client_hooks::{NativeClientHooks, install};
|
||||
use horizon_core::geometry::Vec3;
|
||||
// docs/native-mods.md's example.
|
||||
#[derive(Clone, Debug)]
|
||||
struct ToPost {
|
||||
post: [f64; 3],
|
||||
}
|
||||
impl Goal for ToPost {
|
||||
fn flags(&self) -> u8 {
|
||||
goals::MOVE
|
||||
}
|
||||
fn can_use(&mut self, m: &mut Mob, _: &mut Cx) -> bool {
|
||||
let [x, y, z] = self.post;
|
||||
let (dx, dy, dz) = (m.position.x - x, m.position.y - y, m.position.z - z);
|
||||
dx * dx + dy * dy + dz * dz > 16.0
|
||||
}
|
||||
fn can_continue(&mut self, m: &mut Mob, _: &mut Cx) -> bool {
|
||||
!m.ai.nav.is_done()
|
||||
}
|
||||
fn start(&mut self, m: &mut Mob, cx: &mut Cx) {
|
||||
m.nav_move_to(cx.w, self.post, 1.0);
|
||||
}
|
||||
fn box_clone(&self) -> Box<dyn Goal> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
struct Guards;
|
||||
impl Hooks for Guards {
|
||||
fn register_goals(&self, event: &mut RegisterGoalsEvent<'_>) {
|
||||
if event.mob.kind.id == "ruby:guard" {
|
||||
let ai = &mut event.mob.ai;
|
||||
ai.goals.clear();
|
||||
ai.targets.clear();
|
||||
ai.goals.add(0, goals::Float);
|
||||
ai.goals.add(
|
||||
1,
|
||||
ToPost {
|
||||
post: [0.5, 64.0, 0.5],
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
static MODS: std::sync::LazyLock<Vec<Box<dyn Hooks>>> =
|
||||
std::sync::LazyLock::new(|| vec![Box::new(Guards)]);
|
||||
let installed = install(NativeClientHooks {
|
||||
menu_slot_rules: |_, _, _, _, _| {},
|
||||
living_heal: |_, _, _| {},
|
||||
player_chat: |_, _, _, _, _| {},
|
||||
living_breathe: |_, _, _, _, _| {},
|
||||
living_drown: |_, _, _, _, _, _| {},
|
||||
living_fall: |_, _, _, _, _| {},
|
||||
living_jump: |_, _| {},
|
||||
register_goals: |mob| dispatch_register_goals(&MODS, mob),
|
||||
ingredient: |_, _, _| {},
|
||||
ingredient_test: |_, _, _, _| {},
|
||||
});
|
||||
assert!(installed.is_ok());
|
||||
|
||||
let types = living_mod_types();
|
||||
let mut guard = (*types["ruby:ghoul"]).clone();
|
||||
guard.id = "ruby:guard".into();
|
||||
let at = Vec3::new(0.5, 64.0, 0.5);
|
||||
let guard = Mob::new(1, std::sync::Arc::new(guard), at, 0.0);
|
||||
let ghoul = Mob::new(2, types["ruby:ghoul"].clone(), at, 0.0);
|
||||
assert!(guard.ai.goals.has("Float") && guard.ai.goals.has("ToPost"));
|
||||
assert!(!guard.ai.goals.has("RandomLookAround"));
|
||||
assert!(!guard.ai.targets.has("NearestTarget"));
|
||||
assert!(guard.ai.nav.can_float);
|
||||
assert!(ghoul.ai.goals.has("RandomLookAround") && !ghoul.ai.goals.has("ToPost"));
|
||||
assert!(ghoul.ai.targets.has("NearestTarget"));
|
||||
assert!(!ghoul.ai.nav.can_float, "a zombie has no FloatGoal");
|
||||
}
|
||||
}
|
||||
@@ -11045,6 +11045,8 @@ mod tests {
|
||||
let motion_hooks = free_hooks(include_str!("../motion.rs"));
|
||||
// The chat relay's (`horizon_client_lib::lan::hub::chat`).
|
||||
let chat_hooks = free_hooks(include_str!("../lan/hub/chat.rs"));
|
||||
// The mob AI's (`horizon_client_lib::mobs::ai`).
|
||||
let mob_hooks = free_hooks(include_str!("../mobs/ai.rs"));
|
||||
let declared: BTreeSet<_> = section
|
||||
.lines()
|
||||
.chain(level_section.lines())
|
||||
@@ -11064,6 +11066,7 @@ mod tests {
|
||||
.chain(landing_hooks.iter().copied())
|
||||
.chain(motion_hooks.iter().copied())
|
||||
.chain(chat_hooks.iter().copied())
|
||||
.chain(mob_hooks.iter().copied())
|
||||
.collect();
|
||||
let mut names = BTreeSet::new();
|
||||
let mut contracts = BTreeSet::new();
|
||||
@@ -11100,6 +11103,8 @@ mod tests {
|
||||
} else if breath_hooks.contains(name) {
|
||||
// breathing.rs compiles into horizon-client-lib too.
|
||||
format!("horizon_client_lib::breathing::native_{name}")
|
||||
} else if mob_hooks.contains(name) {
|
||||
format!("horizon_client_lib::mobs::ai::native_{name}")
|
||||
} else if level_hooks.contains(name) {
|
||||
format!("horizon_client::game::native_hooks::GameLevel<'_>::native_{name}")
|
||||
} else {
|
||||
|
||||
@@ -3625,6 +3625,7 @@ pub(crate) mod tests {
|
||||
living_drown: |_, _, _, _, _, _| {},
|
||||
living_fall: |_, _, _, _, _| {},
|
||||
living_jump: |_, _| {},
|
||||
register_goals: |_| {},
|
||||
ingredient: |_, _, _| {},
|
||||
ingredient_test: |_, _, _, _| {},
|
||||
});
|
||||
|
||||
@@ -428,6 +428,7 @@ pub fn register(m: &mut Mob) {
|
||||
Family::Panda => super::panda::register(m),
|
||||
Family::PolarBear => super::polar_bear::register(m),
|
||||
}
|
||||
native_register_goals(m);
|
||||
// FloatGoal's constructor lets the navigation float, as do the
|
||||
// constructors of these brain-driven and flying mobs. A floater rises
|
||||
// 0.04 a tick in Mob.jumpInLiquid, the others 0.3 (the bounce of #542).
|
||||
@@ -445,6 +446,21 @@ pub fn register(m: &mut Mob) {
|
||||
m.ai.nav.can_float = true;
|
||||
}
|
||||
}
|
||||
/// A new mob's goals (`Mob.registerGoals`): `mob.ai.goals` (vanilla's
|
||||
/// `goalSelector`) and `mob.ai.targets` (`targetSelector`) hold the goals of
|
||||
/// its species, which for a mod's type is its template's. Clear them to
|
||||
/// replace them, as a class whose `registerGoals` skips `super`'s, or add
|
||||
/// yours with `GoalSelector::add(priority, goal)`. Each mob gets its own
|
||||
/// goals, so a goal's fields are that mob's state while it lives (for state
|
||||
/// that is saved, see attachments on `Holder::Entity(mob.id)`). Asked for
|
||||
/// every mob Horizon makes, a client's copies of a server's mobs included,
|
||||
/// before its saved data is read; goals tick only where the mob's AI runs.
|
||||
/// `goals::Float` keeps the navigation floating, as FloatGoal's
|
||||
/// constructor does. API @API:register_goals@.
|
||||
#[inline(never)]
|
||||
pub fn native_register_goals(mob: &mut Mob) {
|
||||
crate::native_client_hooks::register_goals(mob);
|
||||
}
|
||||
/// Mob.tick additions that run before the entity tick (cube/creeper fuses, puffing).
|
||||
fn pre_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
if m.species() == Species::SulfurCube {
|
||||
|
||||
@@ -37,6 +37,8 @@ pub use crate::hud_layout::{GuiDraw, HealthHud, HealthInputs};
|
||||
pub use crate::items::ItemStack;
|
||||
pub use crate::merchant::MerchantOffer;
|
||||
pub use crate::mesher::TintSource;
|
||||
pub use crate::mobs::goals::{Cx, Goal, GoalSelector};
|
||||
pub use crate::mobs::{Mob, MobWorld};
|
||||
pub use crate::music::{Music, MusicLayer};
|
||||
pub use crate::particles::{ParticleOptions, ParticleType};
|
||||
pub use crate::recipe_book::RecipeBookCategory;
|
||||
|
||||
@@ -12,6 +12,7 @@ pub struct NativeClientHooks {
|
||||
pub living_drown: fn(u32, &str, &mut bool, &mut f32, i32, &mut bool),
|
||||
pub living_fall: fn(u32, &str, &mut f64, &mut f32, &mut bool),
|
||||
pub living_jump: fn(u32, &str),
|
||||
pub register_goals: fn(&mut crate::mobs::Mob),
|
||||
pub ingredient: fn(&str, &Map<String, Value>, &mut Vec<String>),
|
||||
pub ingredient_test: fn(&str, &Map<String, Value>, &ItemStack, &mut bool),
|
||||
}
|
||||
@@ -103,6 +104,12 @@ pub(crate) fn living_jump(entity: u32, kind: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn register_goals(mob: &mut crate::mobs::Mob) {
|
||||
if let Some(hooks) = HOOKS.get() {
|
||||
(hooks.register_goals)(mob);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ingredient(kind: &str, data: &Map<String, Value>, items: &mut Vec<String>) {
|
||||
if let Some(hooks) = HOOKS.get() {
|
||||
(hooks.ingredient)(kind, data, items);
|
||||
|
||||
@@ -62,6 +62,7 @@ mod tests {
|
||||
pub(super) struct MerchantOffer;
|
||||
pub(super) struct ModFluidTanks;
|
||||
pub(super) struct ModEnergyBuffer;
|
||||
pub(super) struct Mob;
|
||||
pub(super) struct LootStack;
|
||||
pub(super) struct NativeLootContext<'a>(pub &'a ());
|
||||
pub(super) struct NativeDataMapType;
|
||||
|
||||
+1
-1
@@ -178,7 +178,7 @@ using the "Shape" column. What a content pack needs:
|
||||
| Accessory slots (what Curios does) | — | Curios users (6 require it, more use it optionally) | **0.4** | built-in slots API |
|
||||
| Mod networking between its client and server halves | — | machines and up | **0.4** | `net` interface |
|
||||
| Custom recipe types: machine recipes with their own JSON | — | machines and up | **0.4** | recipe-type registry: the mod declares its JSON schema and a display layout for the recipe viewer |
|
||||
| Entities: types, attributes, AI goals and brains, spawning | — | deep (87) | 0.5 | `entity` interface, AI in the mod with vanilla goals reusable; thrown item projectiles, and mobs of a zombie's or a villager's class with their own attributes, are already data in 0.2 ([mods.md](mods.md)) |
|
||||
| Entities: types, attributes, AI goals and brains, spawning | — | deep (87) | 0.5 | `entity` interface, AI in the mod with vanilla goals reusable; thrown item projectiles, and mobs of a zombie's or a villager's class with their own attributes, are already data in 0.2 ([mods.md](mods.md)); a native mod gives a type its own goals with `register_goals` ([native-mods.md](native-mods.md#mob-goals)) |
|
||||
| Custom renderers and animated models (block-entity, entity, item; GeckoLib's format) | — | deep | 0.5 | data models (Bedrock geometry + animations), plus a budgeted draw-list interface for the rest |
|
||||
| Dimensions: dimension types, levels, chunk generators | — | about 10 (Twilight Forest, Aether, Ad Astra, Undergarden, ...) | 0.5 | JSON part with worldgen; custom generators as mod code |
|
||||
| New hooks into vanilla behaviour (what Mixins do) | — | deep | as asked | each becomes a named hook, added for everyone |
|
||||
|
||||
+3
-2
@@ -332,8 +332,9 @@ behaviour, with its own attributes. It saves what its template's class saves und
|
||||
table is its own, `<id>:entities/<name>` (vanilla's `EntityType.getDefaultLootTable`). It is not in vanilla's entity type
|
||||
tags (`#minecraft:undead`, `#minecraft:zombies`...) unless the mod adds it. Its row in `entity-types.json` is its
|
||||
template's, with `template` added and its attributes in place of the template's. Unit tests check all this; it has
|
||||
not yet been tried in a running game. Not yet: its own model or texture (a native mod draws one with
|
||||
`native_mob_submit`), and its own goals.
|
||||
not yet been tried in a running game. Not yet as data: its own model or texture (a native mod draws one with
|
||||
`native_mob_submit`) and its own goals (a native mod gives it them with
|
||||
[`register_goals`](native-mods.md#mob-goals)).
|
||||
|
||||
#### Menu types
|
||||
|
||||
|
||||
@@ -2577,6 +2577,60 @@ place; `native_server_tick` does not define one, so name the dimension and expec
|
||||
- While the game ticks its mobs they are out of the level: from a hook inside that tick, a spawned mob joins after the tick, and the others
|
||||
read as gone.
|
||||
|
||||
### Mob goals
|
||||
|
||||
**`horizon_client_lib::mobs::ai::native_register_goals(mob)`** (vanilla's `Mob.registerGoals`; a trait mod's
|
||||
`Hooks::register_goals(event)`, with `event.mob`) is asked for every new mob, after its species added its goals and before its
|
||||
saved data is read. `mob.ai.goals` is vanilla's `goalSelector`, `mob.ai.targets` its `targetSelector`. A mod's living type
|
||||
([mods.md](mods.md)) comes with its template's goals, a zombie's attacks, strolls and targets: clear them to replace them, as a
|
||||
class whose `registerGoals` skips `super`'s, or add to them. A villager acts through its brain, which goals don't replace:
|
||||
a type from the villager template still works, sleeps and trades as a villager, next to the goals a mod adds. A goal is a
|
||||
Rust type implementing
|
||||
`horizon_client::mobs::goals::Goal` (a bundle compiles into the game, so nothing is loaded at run time): its `flags` (`MOVE`,
|
||||
`LOOK`, `JUMP`, `TARGET`), `can_use`, `can_continue`, `start`, `tick` and `stop` run by priority and flag as vanilla's
|
||||
`GoalSelector` runs them. The vanilla goals in that module (`Float`, `Stroll`, `LookAtPlayer`, `MeleeAttack`, `NearestTarget`
|
||||
and the rest) are there to reuse.
|
||||
|
||||
```rust
|
||||
use horizon_client::mobs::{Mob, goals::{self, Cx, Goal}};
|
||||
use horizon_client::mod_api::{Hooks, RegisterGoalsEvent};
|
||||
|
||||
/// Walks the mob back to its post when it is more than 4 blocks away.
|
||||
#[derive(Clone, Debug)]
|
||||
struct ToPost { post: [f64; 3] }
|
||||
impl Goal for ToPost {
|
||||
fn flags(&self) -> u8 { goals::MOVE }
|
||||
fn can_use(&mut self, m: &mut Mob, _: &mut Cx) -> bool {
|
||||
let [x, y, z] = self.post;
|
||||
let (dx, dy, dz) = (m.position.x - x, m.position.y - y, m.position.z - z);
|
||||
dx * dx + dy * dy + dz * dz > 16.0
|
||||
}
|
||||
fn can_continue(&mut self, m: &mut Mob, _: &mut Cx) -> bool { !m.ai.nav.is_done() }
|
||||
fn start(&mut self, m: &mut Mob, cx: &mut Cx) { m.nav_move_to(cx.w, self.post, 1.0); }
|
||||
fn box_clone(&self) -> Box<dyn Goal> { Box::new(self.clone()) }
|
||||
}
|
||||
|
||||
struct Guards;
|
||||
impl Hooks for Guards {
|
||||
fn register_goals(&self, event: &mut RegisterGoalsEvent<'_>) {
|
||||
if event.mob.kind.id == "example:guard" {
|
||||
let ai = &mut event.mob.ai;
|
||||
ai.goals.clear();
|
||||
ai.targets.clear();
|
||||
ai.goals.add(0, goals::Float);
|
||||
ai.goals.add(1, ToPost { post: [0.5, 64.0, 0.5] });
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
From a goal, `mob.nav_move_to(cx.w, [x, y, z], speed)` walks the mob with the engine's pathfinding (`create_path_to` and
|
||||
`nav_move_to_path` to check a path first), and `mob.ai.mov.set_wanted(x, y, z, speed)` steps it toward a point, for a mod with
|
||||
pathfinding of its own. `goals::Float` keeps the navigation floating, as FloatGoal's constructor does. Each mob gets its own
|
||||
goals, so a goal's fields are that mob's state while it lives; state that is saved goes in an attachment on
|
||||
`Holder::Entity(mob.id)` ([Server attachments](#server-attachments)), from a hook with a host such as `native_entity_tick_pre`.
|
||||
The hook is asked on a client too, for its copies of a server's mobs; goals tick only where the mob's AI runs.
|
||||
|
||||
### Loot tables
|
||||
|
||||
Changing loot a mod didn't make, as NeoForge's `LootTableLoadEvent` and global loot modifiers and Fabric's
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"name": "register_goals",
|
||||
"target": "horizon_client_lib::mobs::ai::native_register_goals",
|
||||
"neoforge": "Mob.registerGoals",
|
||||
"mod_api": {
|
||||
"contract": "listen"
|
||||
}
|
||||
}
|
||||
],
|
||||
"rust_summary": "a new mob's goals (`native_register_goals`, `Mob.registerGoals`).",
|
||||
"doc_summary": "a new mob's goals (`native_register_goals(mob)`, vanilla's `Mob.registerGoals`): `mob.ai.goals` and\n`mob.ai.targets` hold its species' goals, a mod type's template's; a mod clears them to replace them or adds its own\n`Goal`s, which steer the mob with its navigation (`nav_move_to`, `create_path_to`) and controls. Bundles need\n\"hooks\": @API:register_goals@.",
|
||||
"comments": ["crates/horizon-client/src/mobs/ai.rs"]
|
||||
}
|
||||
Reference in new issue
Block a user