Skip to content

Commit b706f67

Browse files
authored
Merge pull request #62 from MHFU-ModKit/village/npc
feat(framework): village NPCs of our own, and a Zinogre that follows the hunter
2 parents b47d79e + 5ded718 commit b706f67

24 files changed

Lines changed: 2672 additions & 50 deletions

File tree

‎framework/README.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,12 @@ mhfu.on_bigmonster_spawn(function(_, species, slot, hp)
3535
end)
3636
```
3737

38-
`lua/examples/` has three to start from: an animation override, a second big monster in a quest,
39-
and the ported Brute Tigrex.
38+
A mod may also define a global `mhfu_tick()`: it runs at 2 Hz in a quest area and while the hunter
39+
roams Pokke village; menus, dialogue, prompt zones and loads pause it.
40+
41+
`lua/examples/` has five to start from: an animation override, a second big monster in a quest,
42+
the ported Brute Tigrex and Zinogre, and a small Zinogre that follows the hunter around the
43+
village (`village_follower.lua`, on the village NPC calls `mhfu.npc_*`).
4044

4145
## Memory stick
4246

‎framework/include/mhfu/mhfu.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
#include "em_vhook.h"
2525
#include "move.h"
2626
#include "monster_events.h"
27+
#include "npc.h"
2728
#include "mips.h"
2829
#include "mod.h"
2930

‎framework/include/mhfu/npc.h‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/* SPDX-License-Identifier: MIT */
2+
/* SPDX-FileCopyrightText: 2026 sp00ktober */
3+
/* Village NPCs of our own (npc.cpp).
4+
*
5+
* Each one is a row appended to Pokke village's spawn table, so the lobby's spawner builds it on
6+
* every load of the main village area. Its model is a PAC staged in extra RAM. It runs no AI,
7+
* cannot be talked to and is not solid. OBJ_VTABLE.BRAIN of NPC_VTABLE is wrapped, so on every
8+
* frame C turns it and switches its clips as asked; the clips' root motion moves it.
9+
*
10+
* The calls only write the block the wrapper reads, so any thread may make them. */
11+
#ifndef MHFU_NPC_H
12+
#define MHFU_NPC_H
13+
14+
#include <stdint.h>
15+
#include "mhfu/types.h"
16+
17+
#ifdef __cplusplus
18+
extern "C" {
19+
#endif
20+
21+
#define MHFU_NPC_MAX 4 /* the lobby's registry holds 20 and the village uses 16 */
22+
#define MHFU_NPC_OWN_KIND 5 /* ours: RESOURCE_TABLE row 21, unused in the village */
23+
#define MHFU_NPC_DEFAULT_CHAR 0x1E /* no spawn condition; ours are never talked to */
24+
#define MHFU_NPC_NONE 0xFFFFu /* mhfu_npc_play's `then`: none */
25+
26+
enum {
27+
MHFU_NPC_FACE_STILL = 0, /* no turn */
28+
MHFU_NPC_FACE_POINT = 1, /* toward world (x, z) */
29+
MHFU_NPC_FACE_HUNTER = 2, /* toward the hunter, offset by (x right, z ahead) in their frame */
30+
};
31+
32+
typedef struct {
33+
const char *pac; /* the model's PAC, a path as mhfu_inject_register takes it; read now */
34+
float size; /* ENTITY.RENDER_SCALE; 1 is the model's own */
35+
uint8_t character; /* NPC_SPAWN_ROW.CHAR; MHFU_NPC_DEFAULT_CHAR unless you need another */
36+
float right; /* where it appears on each load, in the hunter's frame */
37+
float ahead;
38+
} mhfu_npc_spec_t;
39+
40+
typedef struct {
41+
uint32_t object; /* 0 while the village does not show it */
42+
uint32_t frames; /* frames since it spawned */
43+
uint16_t entry; /* the animation-pack entry slot 0 plays */
44+
uint8_t playing; /* slot 0's clip has not ended (CLIP_BLOCK.FLAGS bit 0) */
45+
uint16_t yaw; /* ENTITY.YAW */
46+
mhfu_vec3_t pos; /* ENTITY.POSITION */
47+
float dist; /* XZ distance to the hunter */
48+
} mhfu_npc_status_t;
49+
50+
/* Adds an NPC that spawns on every later load of the village; its slot, or < 0 when full, the
51+
* PAC is unreadable or extra RAM is short. Reads the PAC from the Memory Stick, so call it while
52+
* mods load, never in the village. */
53+
int mhfu_npc_add(const mhfu_npc_spec_t *spec);
54+
55+
/* slot's live object while the village shows it, else 0. */
56+
uint32_t mhfu_npc_object(int slot);
57+
58+
/* Plays `entry` of the PAC's animation pack on clip slots 0-2 from the next frame, cross-fading
59+
* over `blend` frames (0 cuts). When a clip that does not loop ends, `then` plays with the same
60+
* blend; MHFU_NPC_NONE holds the last frame. */
61+
void mhfu_npc_play(int slot, uint16_t entry, uint8_t blend, uint16_t then);
62+
63+
/* Turns slot every frame as `mode` says (MHFU_NPC_FACE_*), by at most `rate` YAW units a frame
64+
* (mhfu/steer.h). */
65+
void mhfu_npc_face(int slot, int mode, float x, float z, uint16_t rate);
66+
67+
/* Arms slot's current face order: once slot is within `dist` of what it faces (FACE_POINT or
68+
* FACE_HUNTER), `entry` plays as mhfu_npc_play(slot, entry, blend, then) would, and the turn
69+
* stops. Checked every frame, so it holds while no mod runs; it fires once, and the next
70+
* mhfu_npc_face disarms it. */
71+
void mhfu_npc_arrive(int slot, float dist, uint16_t entry, uint8_t blend, uint16_t then);
72+
73+
/* Plays slot's clips at `scale` times their own speed from the next frame (NPC.ANIM_SCALE), so it
74+
* walks that much faster too; 1 is the clips' own, 0 freezes them, below 0 counts as 0. Holds
75+
* across loads. */
76+
void mhfu_npc_speed(int slot, float scale);
77+
78+
/* slot's state as of its last frame into out; 0 if slot is not in use. */
79+
int mhfu_npc_status(int slot, mhfu_npc_status_t *out);
80+
81+
#ifdef __cplusplus
82+
} /* extern "C" */
83+
#endif
84+
85+
#endif /* MHFU_NPC_H */

‎framework/include/mhfu/world.h‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,23 @@ extern "C" {
1515
#define MHFU_WORLD_SCREEN_MENU 1
1616
#define MHFU_WORLD_SCREEN_TITLE 4
1717
#define MHFU_WORLD_SCREEN_IN_AREA 17
18-
#define MHFU_WORLD_SCREEN_VILLAGE 22 /* 22: in the village, per the log gate; unverified */
18+
#define MHFU_WORLD_SCREEN_VILLAGE 22 /* boot loads near the save read; village is 0 or 113 */
1919

2020
uint8_t mhfu_world_screen_state(void);
2121
uint16_t mhfu_world_area_index(void); /* the visible section; ENTITY.SECTION's encoding */
2222
uint32_t mhfu_world_quest_timer(void); /* frames left at 30 Hz */
2323
uint32_t mhfu_world_player_hp(void);
2424
mhfu_vec3_t mhfu_world_player_pos(void); /* read from MHFU_CAM_VIEW_EYE */
2525

26-
/* 1 while in an area or the village. Gate every ms0 access on it: the savedata utility
26+
/* 1 while in an area or on screen 22. Gate every ms0 access on it: the savedata utility
2727
* shares the non-reentrant Memory Stick driver, and a concurrent write freezes the load. */
2828
int mhfu_world_ms0_io_safe(void);
2929

30+
/* 1 while the hunter roams Pokke: PLAYER_ENTITY has the village's vtable and SCENE_OBJECT is in
31+
* FREE_ROAM_SCENES. Passive: reading the savedata utility's status would advance it under the
32+
* game. */
33+
int mhfu_world_village_roam(void);
34+
3035
/* Shows every big monster on the map (MHFU_MAP_PAINT); call at 2 Hz or faster. */
3136
void mhfu_world_paint_map(void);
3237

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
-- SPDX-License-Identifier: MIT
2+
-- SPDX-FileCopyrightText: 2026 sp00ktober
3+
-- village_follower.lua: a small Zinogre that follows the hunter around Pokke village. It walks
4+
-- toward a point beside them, on the side away from the camera unless it is well over on the
5+
-- other, turning as it goes, stops there, and turns on the spot to face them when they stand
6+
-- behind it. While they keep moving it keeps pace a slide's length behind that point, its clips
7+
-- sped up or slowed to their speed, so it slides in beside them when they stop. It can't be talked
8+
-- to and isn't solid.
9+
--
10+
-- Needs, once: the port built and placed for injection, from modkit with $MHFU_DATA and
11+
-- $MHP3RD_DATA naming the extracted games,
12+
-- uv run mhfu-port inject ports/zinogre.toml
13+
-- (zinogre.bin in PLUGINS/mhfu_framework/inject/, zinogre_clips.lua in mods/lib/);
14+
-- `memory = 64` in plugin.ini. Cold boot, then walk into the village.
15+
--
16+
-- npc_add reads the PAC from the memory stick, so it runs while mods load and once per boot (a
17+
-- hot reload in the village keeps the NPC it added). The brain is the global mhfu_tick, 2 Hz while
18+
-- the hunter roams: it reads npc_status and gives one order a tick, and C keeps turning and
19+
-- chaining clips between ticks. Every walk arms npc_arrive, so C stops it at its aim on the frame
20+
-- it gets there, also while a dialogue or a prompt zone holds the tick. What plays is learned from
21+
-- status.entry, so a respawn or a clip that ended on its own needs no bookkeeping. Don't run it
22+
-- next to a mhfu_port mod or cli_bridge.lua: they own the tick.
23+
24+
local PAC = "ms0:/PSP/PLUGINS/mhfu_framework/inject/zinogre.bin"
25+
local FPS = 30 -- game frames a second: what a rate counts
26+
local TICK = 0.5 -- seconds between ticks
27+
local SIZE = 0.3 -- of the Zinogre; its root motion scales with it,
28+
-- so every distance below does (measured at 0.3)
29+
30+
local function deg(d) return d * 0x10000 // 360 end
31+
32+
local FAR, FASTER = 1200 * SIZE, 1800 * SIZE -- from the hunter: walk from idle, walk faster
33+
local PACE = 800 * SIZE -- or faster once a moving hunter's aim is this far
34+
local MOVED = 400 * SIZE -- or walk once they moved this far from where it rested
35+
local SIDE = 500 * SIZE -- aims this far beside the hunter, not at them
36+
local SWAP = 120 * SIZE -- changes side once this far over their line
37+
local CAM = 200 * SIZE -- or to the aim this much farther from the camera
38+
-- the stop clip slides ~170 at 0.3 out of either walk, at any clip speed: the arrival fires this
39+
-- far short of the aim, less how far the aim draws off over LEAD seconds of the hunter's walk; never
40+
-- under ARRIVE_MIN, so a tick held while the hunter walks lets it slide past the aim by 90 at most
41+
local SLIDE, LEAD, ARRIVE_MIN = 570 * SIZE, 2, 300 * SIZE
42+
local WALK_MIN = 300 * SIZE -- a walk from idle covers this before it stops,
43+
-- as if the hunter stood: their turn swings the aim
44+
local TURN_MIN = deg(50) -- idle turns start with a still hunter this far off
45+
local TURN_DIST = 240 * SIZE -- closer, the bearing is noise
46+
local WALK_RATE = deg(150) // FPS -- YAW units a frame
47+
local TURN_RATE = 0x4000 // 50 -- a quarter turn over the turn clip's 50 frames
48+
local BLEND = 6 -- frames a clip cross-fades
49+
-- the walks' loops cover 93 and 194 units/s at 0.3 and clip speed 1; the hunter runs 174. While
50+
-- they move, the clip speed holds it SLIDE behind its aim, plus half a tick of the aim's drift:
51+
-- CATCH units/s more per unit over that, within SPEED_MIN..SPEED_MAX
52+
local V_WALK, V_FAST = 310 * SIZE, 650 * SIZE
53+
local MOVING = 50 -- units/s: the hunter keeps moving
54+
local CATCH = 0.5
55+
local SPEED_MIN, SPEED_MAX = 0.7, 1.4
56+
57+
local ok, C = pcall(require, "zinogre_clips") -- the entries, as the PAC was built
58+
if not (ok and C.idle and C.start_walk_forward and C.stop_walk_forward
59+
and C.walk_forwards_faster and C.turn_right and C.turn_left) then
60+
mhfu.log("[village_follower] needs zinogre_clips.lua (mhfu-port inject) naming idle, "
61+
.. "start_walk_forward, stop_walk_forward, walk_forwards_faster, turn_right, turn_left")
62+
return
63+
end
64+
local IDLE, START, STOP_CLIP, FAST = C.idle, C.start_walk_forward, C.stop_walk_forward,
65+
C.walk_forwards_faster
66+
local TURN_R, TURN_L = C.turn_right, C.turn_left
67+
68+
local slot = _G.village_follower_slot
69+
if slot == nil then
70+
local added, err = mhfu.npc_add(PAC, { size = SIZE, right = SIDE }) -- spawns at its aim
71+
if not added then mhfu.log("[village_follower] npc_add failed: " .. tostring(err)) end
72+
slot = added or false
73+
_G.village_follower_slot = slot
74+
end
75+
if not slot then return end
76+
mhfu.npc_speed(slot, 1) -- C keeps it over a hot reload
77+
78+
local function say(fmt, ...) mhfu.log(string.format("[village_follower] " .. fmt, ...)) end
79+
80+
-- the hunter's own position: mhfu.player_pos() is the camera eye
81+
local HUNTER = mhfu.addr.PLAYER_ENTITY + mhfu.addr.ENTITY.TRANSLATION
82+
local HUNTER_YAW = mhfu.addr.PLAYER_ENTITY + mhfu.addr.ENTITY.YAW
83+
84+
local seen -- the last status, nil while not shown
85+
local was -- the hunter at the last tick
86+
local rested -- where the hunter was when it came to rest
87+
local side = SIDE -- the walk's aim: right of the hunter, or left
88+
local speed = 1 -- the clip speed given last
89+
90+
--- The hunter: where, facing (radians, as YAW), their speed since the last tick, and whether they
91+
--- keep moving.
92+
local function hunter(st)
93+
local h = { x = mhfu.read_f32(HUNTER), z = mhfu.read_f32(HUNTER + 8),
94+
yaw = mhfu.read_u16(HUNTER_YAW) * math.pi / 0x8000, vx = 0, vz = 0 }
95+
local dt = was and (st.frames - was.frames) / FPS or 0
96+
if dt > 0 and dt < 2 then h.vx, h.vz = (h.x - was.x) / dt, (h.z - was.z) / dt end
97+
h.moving = math.sqrt(h.vx ^ 2 + h.vz ^ 2) > MOVING
98+
was = { x = h.x, z = h.z, frames = st.frames }
99+
return h
100+
end
101+
102+
--- Distance from the NPC to the point `side` right of the hunter (left when negative), where
103+
--- they will be after `lead` seconds at their speed.
104+
local function to_aim(st, h, lead)
105+
local x = h.x + h.vx * lead - side * math.cos(h.yaw)
106+
local z = h.z + h.vz * lead + side * math.sin(h.yaw)
107+
return math.sqrt((x - st.x) ^ 2 + (z - st.z) ^ 2)
108+
end
109+
110+
--- Picks `side`: the one whose aim is farther from the camera at `eye`, so it hides the hunter
111+
--- less, unless it is well over on the other side (more beside the hunter than behind them); else
112+
--- the side it is on, so a turn of theirs never has it cross them. CAM and SWAP keep it from
113+
--- flipping each tick.
114+
local function pick_side(st, h, eye)
115+
local c, s = math.cos(h.yaw), math.sin(h.yaw)
116+
local dx, dz = st.x - h.x, st.z - h.z
117+
local right, behind = dz * s - dx * c, -(dx * s + dz * c)
118+
local far = 0 -- the right aim's distance from the eye, less the left's
119+
if eye then
120+
far = math.sqrt((h.x - SIDE * c - eye.x) ^ 2 + (h.z + SIDE * s - eye.z) ^ 2)
121+
- math.sqrt((h.x + SIDE * c - eye.x) ^ 2 + (h.z - SIDE * s - eye.z) ^ 2)
122+
end
123+
local want = far > CAM and SIDE or far < -CAM and -SIDE or nil
124+
if want and right * want / SIDE >= -math.max(SWAP, behind) then
125+
side = want
126+
elseif math.abs(right) > SWAP then
127+
side = right < 0 and -SIDE or SIDE
128+
end
129+
end
130+
131+
--- How far from its aim the arrival stops a walk; how far the aim is now; how fast it draws off.
132+
local function arrival(st, h)
133+
local now = to_aim(st, h, 0)
134+
local away = (to_aim(st, h, LEAD) - now) / LEAD
135+
return math.max(ARRIVE_MIN, SLIDE - away * LEAD), now, away
136+
end
137+
138+
--- Clip speed `x`, in steps of 0.05, given when it changes.
139+
local function set_speed(x)
140+
x = math.floor(x * 20 + 0.5) / 20
141+
if x ~= speed then
142+
speed = x
143+
mhfu.npc_speed(slot, x)
144+
end
145+
end
146+
147+
--- Arms the stop of a walk on `entry` at its aim (npc_face disarms it, so it follows every face),
148+
--- and paces it: clip speed 1 toward a still hunter.
149+
local function arm(st, h, entry)
150+
local stop, now, away = arrival(st, h)
151+
mhfu.npc_arrive(slot, stop, STOP_CLIP, BLEND, IDLE)
152+
local x = 1
153+
if h.moving then
154+
local hold = SLIDE + math.max(away, 0) * TICK / 2
155+
x = (away + CATCH * (now - hold)) / (entry == FAST and V_FAST or V_WALK)
156+
x = math.min(SPEED_MAX, math.max(SPEED_MIN, x))
157+
end
158+
set_speed(x)
159+
end
160+
161+
--- Walks on `entry` (none: the one playing) toward `side` of the hunter, stopping there.
162+
local function walk(st, h, entry)
163+
if entry then mhfu.npc_play(slot, entry, BLEND) end
164+
mhfu.npc_face(slot, "hunter", side, 0, WALK_RATE)
165+
arm(st, h, entry or st.entry)
166+
end
167+
168+
--- Walks faster from now on: past FASTER, or past PACE while the hunter keeps moving.
169+
local function faster(st, h, now)
170+
return st.dist > FASTER or h.moving and now > PACE
171+
end
172+
173+
--- YAW units from the NPC's facing to the hunter, in -0x8000..0x7FFF. Positive is toward its left,
174+
--- the way YAW grows, which is turn_left's way.
175+
local function bearing_error(st, h)
176+
local want = math.floor(math.atan(h.x - st.x, h.z - st.z) * 32768 / math.pi + 0.5)
177+
return (want - st.yaw + 0x8000) % 0x10000 - 0x8000
178+
end
179+
180+
local function stand(blend)
181+
mhfu.npc_play(slot, IDLE, blend)
182+
mhfu.npc_face(slot, "still")
183+
end
184+
185+
local function from_idle(st, h, eye)
186+
local err = bearing_error(st, h)
187+
rested = rested or { x = h.x, z = h.z }
188+
if not h.moving and st.dist > TURN_DIST and math.abs(err) > TURN_MIN then
189+
say("turn %s, %.0f deg off", err > 0 and "left" or "right", math.abs(err) * 360 / 0x10000)
190+
mhfu.npc_play(slot, err > 0 and TURN_L or TURN_R, BLEND, IDLE)
191+
mhfu.npc_face(slot, "hunter", 0, 0, TURN_RATE)
192+
return
193+
end
194+
pick_side(st, h, eye)
195+
local moved = math.sqrt((h.x - rested.x) ^ 2 + (h.z - rested.z) ^ 2)
196+
local stop, now = arrival(st, h)
197+
local fast = faster(st, h, now)
198+
if (st.dist > FAR or moved > MOVED) and (fast or now - math.max(stop, SLIDE) > WALK_MIN) then
199+
say("walk%s, %.0f away", fast and " faster" or "", st.dist)
200+
rested = nil
201+
walk(st, h, fast and FAST or START)
202+
else
203+
mhfu.npc_face(slot, "still") -- a turn's gentle facing ends with its clip
204+
end
205+
end
206+
207+
--- C stops the walk at its aim; each tick re-aims and re-paces it as the hunter moves.
208+
local function from_walk(st, h, eye)
209+
local was_side = side
210+
pick_side(st, h, eye)
211+
if st.entry == START and faster(st, h, to_aim(st, h, 0)) then
212+
say("walk faster, %.0f away", st.dist)
213+
walk(st, h, FAST) -- never back: START would replay its start-up
214+
elseif side ~= was_side then
215+
walk(st, h)
216+
else
217+
arm(st, h, st.entry)
218+
end
219+
end
220+
221+
function mhfu_tick()
222+
local st = mhfu.npc_status(slot)
223+
if not st or st.object == 0 then seen = nil; return end
224+
local born = not seen or st.object ~= seen.object or st.frames < seen.frames
225+
seen = st
226+
local h = hunter(st)
227+
local ex, _, ez = mhfu.player_pos()
228+
local eye = ez and { x = ex, z = ez }
229+
local e = st.entry
230+
if born or (e ~= START and e ~= FAST) then set_speed(1) end
231+
if born then
232+
say("spawned")
233+
rested = nil
234+
stand(0)
235+
elseif e == IDLE then
236+
from_idle(st, h, eye)
237+
elseif e == START or e == FAST then
238+
from_walk(st, h, eye)
239+
elseif (e ~= STOP_CLIP and e ~= TURN_R and e ~= TURN_L) or not st.playing then
240+
stand(BLEND) -- a clip nobody asked for, or one that ended
241+
elseif h.moving and faster(st, h, to_aim(st, h, 0)) then
242+
pick_side(st, h, eye) -- a stop or turn gives way when they run on
243+
say("walk faster, %.0f away", st.dist)
244+
walk(st, h, FAST)
245+
end
246+
end
247+
248+
say("ready")

0 commit comments

Comments
 (0)