|
| 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