mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-28 23:00:21 -04:00
420 lines
14 KiB
Lua
420 lines
14 KiB
Lua
-- Game3 Trainer Line of Sight Engine (FRLG authentic).
|
|
-- Handles directional raycast vision, elevation & ledge masking, exclamation bubble,
|
|
-- walk-up approach tracking, zero-distance skips, and battle script engagement.
|
|
|
|
local Flags = require("src.core.game3.scripting.flags")
|
|
local Ctx = require("src.core.game3.scripting.ctx")
|
|
local ModRuntime = require("src.mods.Runtime")
|
|
|
|
local TrainerSight = {}
|
|
|
|
local DELTA = {
|
|
up = { 0, -1 },
|
|
down = { 0, 1 },
|
|
left = { -1, 0 },
|
|
right = { 1, 0 },
|
|
}
|
|
|
|
local OPPOSITE_FACING = {
|
|
down = "up",
|
|
up = "down",
|
|
left = "right",
|
|
right = "left",
|
|
}
|
|
|
|
-- pret MetatileBehavior ledge jump bytes (MB_JUMP_*)
|
|
local LEDGE_BEHAVIORS = {
|
|
[0x38] = true, -- MB_JUMP_SOUTH
|
|
[0x39] = true, -- MB_JUMP_NORTH
|
|
[0x3A] = true, -- MB_JUMP_WEST
|
|
[0x3B] = true, -- MB_JUMP_EAST
|
|
[0xA0] = true, -- MB_JUMP_EAST_IGNORE_SLOPE
|
|
[0xA1] = true, -- MB_JUMP_WEST_IGNORE_SLOPE
|
|
[0xA2] = true, -- MB_JUMP_NORTH_IGNORE_SLOPE
|
|
[0xA3] = true, -- MB_JUMP_SOUTH_IGNORE_SLOPE
|
|
}
|
|
|
|
local function Player()
|
|
return package.loaded["src.core.game3.player"]
|
|
or require("src.core.game3.player")
|
|
end
|
|
|
|
local function Objects()
|
|
return package.loaded["src.core.game3.objects"]
|
|
or require("src.core.game3.objects")
|
|
end
|
|
|
|
local function Collision()
|
|
return package.loaded["src.core.game3.collision"]
|
|
or require("src.core.game3.collision")
|
|
end
|
|
|
|
local function Field()
|
|
return package.loaded["src.core.game3.field"]
|
|
or require("src.core.game3.field")
|
|
end
|
|
|
|
local function Space()
|
|
return package.loaded["src.core.game3.scripting.space"]
|
|
or require("src.core.game3.scripting.space")
|
|
end
|
|
|
|
local function FieldEffects()
|
|
return package.loaded["src.core.game3.field_effects"]
|
|
or require("src.core.game3.field_effects")
|
|
end
|
|
|
|
--- Extract numeric trainer ID from EventObject def or script bytecode.
|
|
function TrainerSight.getTrainerId(eo)
|
|
if not eo then return nil end
|
|
if eo.trainerId then return eo.trainerId end
|
|
if eo.def and eo.def.trainerId then
|
|
eo.trainerId = tonumber(eo.def.trainerId)
|
|
return eo.trainerId
|
|
end
|
|
local scriptKey = eo.scriptKey or (eo.def and eo.def.scriptKey)
|
|
if not scriptKey then return nil end
|
|
local Sp = Space()
|
|
local scripts = Sp and Sp.bundle and Sp.bundle.scripts
|
|
local list = scripts and scripts[scriptKey]
|
|
if type(list) == "table" then
|
|
for _, row in ipairs(list) do
|
|
if row.op == "trainerbattle" or row.op == "dotrainerbattle" then
|
|
local tid = tonumber(row.trainer or row[1])
|
|
if tid then
|
|
eo.trainerId = tid
|
|
eo.trainerBattleType = tonumber(row.type) or 0
|
|
return tid
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- pokefirered/src/trainer_see.c:97
|
|
function TrainerSight.isTrainerType(eo)
|
|
if not eo then return false end
|
|
local tt = tonumber(eo.trainerType or (eo.def and eo.def.trainerType)) or 0
|
|
return tt == 1 or tt == 3
|
|
end
|
|
|
|
--- Check if trainer has already been defeated.
|
|
function TrainerSight.isDefeated(eo, store, ctx)
|
|
if not eo then return true end
|
|
local tid = TrainerSight.getTrainerId(eo)
|
|
if tid then
|
|
local fid = Flags.trainerFlagId(tid)
|
|
if Flags.getFlag(store, ctx, fid) then
|
|
return true
|
|
end
|
|
end
|
|
-- Check template defeat flag if present
|
|
local flag = eo.flag or (eo.def and (eo.def.flag or eo.def.flagId))
|
|
if flag and flag ~= 0 and flag ~= 0xFFFF and flag ~= 65535 then
|
|
if Flags.getFlag(store, ctx, flag) then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
function TrainerSight.battleType(eo)
|
|
if not eo then return nil end
|
|
if eo.trainerBattleType == nil then
|
|
local Sp = Space()
|
|
local scriptKey = eo.scriptKey or (eo.def and eo.def.scriptKey)
|
|
local list = scriptKey and Sp and Sp.bundle and Sp.bundle.scripts and Sp.bundle.scripts[scriptKey]
|
|
eo.trainerBattleType = false
|
|
for _, row in ipairs(type(list) == "table" and list or {}) do
|
|
if row.op == "trainerbattle" or row.op == "dotrainerbattle" then
|
|
eo.trainerBattleType = tonumber(row.type) or 0
|
|
break
|
|
end
|
|
end
|
|
end
|
|
return eo.trainerBattleType or nil
|
|
end
|
|
|
|
-- pokefirered/src/trainer_see.c:114
|
|
function TrainerSight.blockedByDoubles(eo)
|
|
if TrainerSight.battleType(eo) ~= 4 then return false end
|
|
local Party = require("src.core.game3.party")
|
|
local Runtime = package.loaded["src.core.game3.runtime"]
|
|
local session = Runtime and Runtime.getSession and Runtime.getSession()
|
|
return Party.monsStateToDoubles(session and session.party) ~= Party.PLAYER_HAS_TWO_USABLE_MONS
|
|
end
|
|
|
|
--- Test if a metatile behavior byte represents a one-way ledge hop.
|
|
local function is_ledge_tile(game, cx, cy)
|
|
local Coll = Collision()
|
|
if Coll.isLedge and Coll.isLedge(cx, cy) then
|
|
return true
|
|
end
|
|
local cellByte = Coll.cell and Coll.cell(cx, cy)
|
|
if cellByte and LEDGE_BEHAVIORS[cellByte] then
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
--- Directional line-of-sight raycast verifying elevation, obstacles, ledges, and intermediaries.
|
|
-- @return spotted (bool), dist (number)
|
|
function TrainerSight.checkLineOfSight(eo, P, game)
|
|
if not eo or not P then return false, 0 end
|
|
local facing = eo.facing or "down"
|
|
local d = DELTA[facing]
|
|
if not d then return false, 0 end
|
|
|
|
-- src/trainer_see.c:151
|
|
local ex = eo.moving and eo.targetX or eo.cellX
|
|
local ey = eo.moving and eo.targetY or eo.cellY
|
|
local px, py = P.cellX, P.cellY
|
|
local dx, dy = d[1], d[2]
|
|
local dist = 0
|
|
|
|
if dx ~= 0 then
|
|
if py ~= ey then return false, 0 end
|
|
if (px - ex) * dx <= 0 then return false, 0 end
|
|
dist = math.abs(px - ex)
|
|
elseif dy ~= 0 then
|
|
if px ~= ex then return false, 0 end
|
|
if (py - ey) * dy <= 0 then return false, 0 end
|
|
dist = math.abs(py - ey)
|
|
end
|
|
|
|
local sight = tonumber(eo.sight or (eo.def and (eo.def.sight or eo.def.trainerRange))) or 0
|
|
if dist < 1 or dist > sight then
|
|
return false, 0
|
|
end
|
|
|
|
local Coll = Collision()
|
|
local Objs = Objects()
|
|
local eoElev = tonumber(eo.currentElevation) or 0
|
|
local mapDef = eo.mapDef or Coll._mapDef
|
|
|
|
-- pokefirered/src/trainer_see.c:225
|
|
if Objs.elevationsCompatible and not Objs.elevationsCompatible(eoElev, P.currentElevation) then
|
|
return false, 0
|
|
end
|
|
if Coll.elevationMismatchOn and Coll.elevationMismatchOn(mapDef, eoElev, px, py) then
|
|
return false, 0
|
|
end
|
|
|
|
-- Raycast intermediate tiles strictly between trainer and player
|
|
for step = 1, dist - 1 do
|
|
local cx = ex + dx * step
|
|
local cy = ey + dy * step
|
|
local fromX = ex + dx * (step - 1)
|
|
local fromY = ey + dy * (step - 1)
|
|
|
|
-- 1. Collision check: must be passable along raycast direction
|
|
if Coll.canEnter and not Coll.canEnter(game, cx, cy, { fromX = fromX, fromY = fromY, dir = facing, elevation = eoElev }) then
|
|
return false, 0
|
|
end
|
|
-- pokefirered/src/trainer_see.c:214
|
|
if Coll.elevationMismatchOn and Coll.elevationMismatchOn(mapDef, eoElev, cx, cy) then
|
|
return false, 0
|
|
end
|
|
|
|
-- 2. Ledge masking: line of sight cannot penetrate one-way ledge boundaries
|
|
if is_ledge_tile(game, cx, cy) then
|
|
return false, 0
|
|
end
|
|
|
|
-- 3. Intermediary NPCs block vision
|
|
if Objs.blocks and Objs.blocks(cx, cy, eo.localId, eoElev) then
|
|
return false, 0
|
|
end
|
|
local other = Objs.at and Objs.at(cx, cy)
|
|
if other and (not Objs.elevationsCompatible or Objs.elevationsCompatible(eoElev, other.currentElevation)) then
|
|
return false, 0
|
|
end
|
|
end
|
|
|
|
-- Check player tile for ledge boundary
|
|
if is_ledge_tile(game, px, py) then
|
|
return false, 0
|
|
end
|
|
|
|
return true, dist
|
|
end
|
|
|
|
--- Engage trainer encounter: play '!' exclamation bubble, walk up (or skip if dist == 1), and launch script.
|
|
function TrainerSight.engage(game, eo, dist)
|
|
local F = Field()
|
|
local P = Player()
|
|
local Objs = Objects()
|
|
local Sp = Space()
|
|
local Fx = FieldEffects()
|
|
|
|
-- 1. Strictly lock overworld state immediately
|
|
F.locked = true
|
|
eo.frozen = true
|
|
eo.scriptBusy = true
|
|
|
|
-- 2. Turn trainer to face player directly
|
|
local playerFacing = OPPOSITE_FACING[eo.facing] or "up"
|
|
|
|
-- 3. Play encounter music immediately when trainer spots player (pret PlayTrainerEncounterMusic / EventScript_DoTrainerBattleFromApproach)
|
|
local tid = TrainerSight.getTrainerId(eo)
|
|
local okT, Trainers = pcall(require, "src.core.game3.scripting.trainers")
|
|
-- pokefirered/src/trainer_see.c:105
|
|
if ModRuntime.wants("world.trainer_engaged") then
|
|
local info = okT and Trainers and tid and Trainers.info and Trainers.info(tid) or nil
|
|
local Map = package.loaded["src.core.game3.map"]
|
|
ModRuntime.emit("world.trainer_engaged", {
|
|
npc = eo, trainerClass = info and info.class, partyIndex = tid, trainerId = tid,
|
|
mapId = Map and Map.current, sight = { distance = dist, facing = eo.facing },
|
|
})
|
|
end
|
|
local musicId = okT and Trainers and Trainers.getEncounterMusic and Trainers.getEncounterMusic(tid)
|
|
if not musicId then
|
|
musicId = 285 -- MUS_ENCOUNTER_BOY fallback
|
|
end
|
|
local okA, Audio = pcall(require, "src.core.game3.audio")
|
|
if okA and Audio and Audio.playSong then
|
|
Audio.playSong(musicId)
|
|
end
|
|
|
|
-- 4. Play exclamation animation and sound effect SE_PIN (21)
|
|
Fx.startExclamation(eo, function()
|
|
local scriptKey = eo.scriptKey or (eo.def and eo.def.scriptKey)
|
|
|
|
local function finishEngagement()
|
|
P.facing = playerFacing
|
|
-- pokefirered/src/trainer_see.c:349-351 SetTrainerMovementType, OverrideMovementTypeForObjectEvent, OverrideTemplateCoordsForObjectEvent
|
|
local faceMt = ({ down = 0x08, up = 0x07, left = 0x09, right = 0x0A })[eo.facing] or 0x08
|
|
if Objs.setTrainerMovementType then
|
|
Objs.setTrainerMovementType(eo, faceMt)
|
|
else
|
|
eo.movementType = faceMt
|
|
eo.movement = "STAY"
|
|
eo.range = (eo.facing or "down"):upper()
|
|
end
|
|
if Objs.overrideTemplateMovementType then
|
|
Objs.overrideTemplateMovementType(eo.localId, faceMt)
|
|
end
|
|
eo.homeX = eo.cellX
|
|
eo.homeY = eo.cellY
|
|
if eo.def then
|
|
eo.def.movementType = faceMt
|
|
eo.def.movement = "STAY"
|
|
eo.def.x = eo.cellX
|
|
eo.def.y = eo.cellY
|
|
eo.def.range = (eo.facing or "down"):upper()
|
|
end
|
|
if Objs.rememberPerm and Objs._mapId then
|
|
Objs.rememberPerm(Objs._mapId, eo.localId, {
|
|
x = eo.cellX,
|
|
y = eo.cellY,
|
|
movementType = faceMt,
|
|
facing = eo.facing,
|
|
})
|
|
end
|
|
eo.frozen = false
|
|
eo.scriptBusy = false
|
|
F.locked = false
|
|
if scriptKey then
|
|
local dirs = { down = 1, up = 2, left = 3, right = 4 }
|
|
local facingDir = dirs[P.facing] or 1
|
|
if Sp and Sp.startScript then
|
|
Sp.startScript(scriptKey, eo.localId, facingDir)
|
|
elseif Sp and Sp.runScript then
|
|
local Runtime = package.loaded["src.core.game3.runtime"]
|
|
local mod = Runtime and Runtime._mod
|
|
local g = game or (Runtime and Runtime._game)
|
|
local world = g and (g.overworld or g.world)
|
|
Sp.runScript(mod, scriptKey, g, world, eo.localId)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Walk-up Approach Tracking: walk dist - 1 steps toward player, or skip if adjacent (dist == 1)
|
|
local walkSteps = dist - 1
|
|
if walkSteps <= 0 then
|
|
finishEngagement()
|
|
else
|
|
local actions = {}
|
|
for _ = 1, walkSteps do
|
|
actions[#actions + 1] = { kind = "step", dir = eo.facing }
|
|
end
|
|
Objs.startTrack(eo.localId, actions, function()
|
|
finishEngagement()
|
|
end)
|
|
end
|
|
end)
|
|
end
|
|
|
|
--- Main line of sight check.
|
|
-- @param game game instance
|
|
-- @param specificTrainer optional specific EventObject to check (e.g. from spinning NPC turn update)
|
|
-- @return boolean true if an encounter was triggered
|
|
function TrainerSight.check(game, specificTrainer)
|
|
local F = Field()
|
|
if F.locked then return false end
|
|
|
|
local P = Player()
|
|
if P.moving then return false end
|
|
|
|
local Warp = package.loaded["src.core.game3.warp"]
|
|
if Warp and Warp.isBusy and Warp.isBusy() then return false end
|
|
|
|
local Runtime = package.loaded["src.core.game3.runtime"]
|
|
if Runtime and Runtime.uiBusy and Runtime.uiBusy() then return false end
|
|
|
|
local Sp = Space()
|
|
if Sp and Sp.vm and Sp.vm.isRunning and Sp.vm:isRunning() then return false end
|
|
|
|
local Message = package.loaded["src.ui.game3.message"]
|
|
if Message and Message.isOpen and Message.isOpen() then return false end
|
|
|
|
local Battle = package.loaded["src.core.game3.battle"]
|
|
if Battle and Battle.isActive and Battle.isActive() then return false end
|
|
|
|
local store = (Sp and Sp.store) or Flags.newStore()
|
|
local ctx = (Sp and Sp.vm and Sp.vm.ctx) or Ctx.new()
|
|
|
|
local Objs = Objects()
|
|
|
|
if specificTrainer then
|
|
local eo = specificTrainer
|
|
-- src/trainer_see.c:94
|
|
if Objs.find(eo.localId) ~= eo then return false end
|
|
if eo.visible and not eo.hidden and not eo.scriptBusy and not eo.frozen then
|
|
local sight = tonumber(eo.sight or (eo.def and (eo.def.sight or eo.def.trainerRange))) or 0
|
|
if sight > 0 and TrainerSight.isTrainerType(eo)
|
|
and not TrainerSight.isDefeated(eo, store, ctx) then
|
|
local spotted, dist = TrainerSight.checkLineOfSight(eo, P, game)
|
|
if spotted and not TrainerSight.blockedByDoubles(eo) then
|
|
TrainerSight.engage(game, eo, dist)
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Simultaneous Spot Prioritization: iterate candidates in strict ascending localId order
|
|
local order = Objs._order or {}
|
|
for _, lid in ipairs(order) do
|
|
local eo = Objs.find(lid)
|
|
if eo and eo ~= P and eo.visible and not eo.hidden and not eo.scriptBusy and not eo.frozen then
|
|
local sight = tonumber(eo.sight or (eo.def and (eo.def.sight or eo.def.trainerRange))) or 0
|
|
if sight > 0 and TrainerSight.isTrainerType(eo)
|
|
and not TrainerSight.isDefeated(eo, store, ctx) then
|
|
local spotted, dist = TrainerSight.checkLineOfSight(eo, P, game)
|
|
if spotted and not TrainerSight.blockedByDoubles(eo) then
|
|
-- Immediately engage and break iterator to suppress any other simultaneous spots
|
|
TrainerSight.engage(game, eo, dist)
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
return TrainerSight
|