Merge pull request #139 from MiyazakiTheFalse/codex/add-chase-overworld-subsystem-for-chaser-events

Add overworld chase chasers with spawn/despawn and pursuit updates
This commit is contained in:
MiyazakiTheFalse 2026-03-07 00:51:40 +00:00 committed by GitHub
commit 7bf2de20f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 248 additions and 2 deletions

View File

@ -1,6 +1,6 @@
# Chase boundary policy
This document defines where an **active chase** is allowed to continue in the overworld.
This document defines where an **active chase** is allowed to continue in the overworld and how chase visuals move while the chase state is active.
## Continuation gate
@ -38,6 +38,26 @@ Encounter-eligible tiles are used for **re-engagement attempts only** (forced ch
- Active chase can persist while crossing safe/non-encounter tiles (for example, route path tiles) as long as map context remains valid.
- Forced chase encounter generation resumes once the player steps back onto an encounter-eligible tile before chase timeout expires.
## Overworld chaser movement policy
When `ChaseStamina_IsChaseActive()` is true, a chase-overworld layer drives 1..`ChaseStamina_GetActiveChasers()` visual object events.
- Chasers use a reserved local-id range (`230..231`) and a dedicated chase graphics id (`OBJ_EVENT_GFX_MEOWTH`).
- Spawn point is biased behind the player when possible; each chaser then pursues every overworld frame.
- Target coordinate selection prefers the live player object-event coordinates and falls back to `gSaveBlock1Ptr->pos` when needed.
- Movement picks a primary axis toward player distance, then tries fallback directions (secondary axis, current facing, opposite facing).
- Every attempted step is validated with object collision checks; if all checks fail the chaser turns in place toward the player instead of forcing illegal movement.
This policy keeps chasers visible and responsive while avoiding collision softlocks.
## Edge cases and invalidation handling
- **Chase termination (`EndChase`)**: all spawned chaser object events are explicitly removed immediately.
- **Map transitions / warps**: transition hooks despawn all chase object events before map context changes settle.
- **Context mismatch safety**: if spawned chasers belong to a previous map context, the chase-overworld layer invalidates and despawns them before any updates.
- **Doors/buildings/safe hubs**: entering non-chase contexts still terminates the chase state per core policy above, and visual chasers are cleaned up with that termination.
- **Ledges / blocked terrain**: collision fallback can choose alternate legal directions or no-step facing updates, preventing repeated illegal moves.
## Suppression safety rule
`ChaseStamina_ShouldSuppressRandomEncounters()` re-checks map-context validity. If the map context is invalid, it clears chase state and returns `FALSE` so random-encounter suppression cannot persist outside chase-valid zones.
@ -52,4 +72,4 @@ State-machine coverage should include the following sequence:
4. Player returns to grass before timeout.
5. Forced chase re-engagement is again eligible on encounter-valid terrain.
Future tuning should update this document alongside `src/chase_stamina.c` so map/terrain rules stay explicit.
Future tuning should update this document alongside `src/chase_stamina.c` and `src/chase_overworld.c` so map, terrain, and visual pursuit behavior stay explicit.

10
include/chase_overworld.h Normal file
View File

@ -0,0 +1,10 @@
#ifndef GUARD_CHASE_OVERWORLD_H
#define GUARD_CHASE_OVERWORLD_H
#include "global.h"
void ChaseOverworld_UpdateOverworldFrame(bool8 tookStep);
void ChaseOverworld_OnMapTransition(const struct WarpData *from, const struct WarpData *to);
void ChaseOverworld_OnChaseEnded(void);
#endif // GUARD_CHASE_OVERWORLD_H

209
src/chase_overworld.c Normal file
View File

@ -0,0 +1,209 @@
#include "global.h"
#include "chase_overworld.h"
#include "chase_stamina.h"
#include "event_object_movement.h"
#include "fieldmap.h"
#include "constants/event_object_movement.h"
#include "constants/event_objects.h"
#include "constants/maps.h"
#define CHASE_OVERWORLD_MAX_CHASERS 2
#define CHASE_OVERWORLD_LOCAL_ID_BASE 230
#define CHASE_OVERWORLD_GFX_ID OBJ_EVENT_GFX_MEOWTH
static EWRAM_DATA bool8 sChasersSpawned = FALSE;
static EWRAM_DATA u8 sSpawnedMapGroup = MAP_GROUP(MAP_UNDEFINED);
static EWRAM_DATA u8 sSpawnedMapNum = MAP_NUM(MAP_UNDEFINED);
static void DespawnChasers(void)
{
u8 i;
for (i = 0; i < CHASE_OVERWORLD_MAX_CHASERS; i++)
RemoveObjectEventByLocalIdAndMap(CHASE_OVERWORLD_LOCAL_ID_BASE + i, sSpawnedMapNum, sSpawnedMapGroup);
sChasersSpawned = FALSE;
sSpawnedMapGroup = MAP_GROUP(MAP_UNDEFINED);
sSpawnedMapNum = MAP_NUM(MAP_UNDEFINED);
}
static bool8 IsSpawnContextValid(void)
{
return sChasersSpawned
&& sSpawnedMapGroup == gSaveBlock1Ptr->location.mapGroup
&& sSpawnedMapNum == gSaveBlock1Ptr->location.mapNum;
}
static u8 GetMoveDirectionTowardTarget(s16 fromX, s16 fromY, s16 toX, s16 toY, u8 preferredDir)
{
if (fromX < toX)
return DIR_EAST;
if (fromX > toX)
return DIR_WEST;
if (fromY < toY)
return DIR_SOUTH;
if (fromY > toY)
return DIR_NORTH;
return preferredDir;
}
static bool8 TryQueueChaserStep(struct ObjectEvent *objectEvent, s16 targetX, s16 targetY)
{
s16 dx;
s16 dy;
u8 i;
u8 primaryDir;
u8 secondaryDir;
u8 fallbackDirs[4];
primaryDir = DIR_NONE;
secondaryDir = DIR_NONE;
dx = objectEvent->currentCoords.x - targetX;
if (dx < 0)
dx = -dx;
dy = objectEvent->currentCoords.y - targetY;
if (dy < 0)
dy = -dy;
if (dx >= dy)
{
if (objectEvent->currentCoords.x < targetX)
primaryDir = DIR_EAST;
else if (objectEvent->currentCoords.x > targetX)
primaryDir = DIR_WEST;
if (objectEvent->currentCoords.y < targetY)
secondaryDir = DIR_SOUTH;
else if (objectEvent->currentCoords.y > targetY)
secondaryDir = DIR_NORTH;
}
else
{
if (objectEvent->currentCoords.y < targetY)
primaryDir = DIR_SOUTH;
else if (objectEvent->currentCoords.y > targetY)
primaryDir = DIR_NORTH;
if (objectEvent->currentCoords.x < targetX)
secondaryDir = DIR_EAST;
else if (objectEvent->currentCoords.x > targetX)
secondaryDir = DIR_WEST;
}
if (primaryDir == DIR_NONE)
primaryDir = GetMoveDirectionTowardTarget(objectEvent->currentCoords.x, objectEvent->currentCoords.y, targetX, targetY, objectEvent->facingDirection);
fallbackDirs[0] = primaryDir;
fallbackDirs[1] = secondaryDir;
fallbackDirs[2] = objectEvent->facingDirection;
fallbackDirs[3] = GetOppositeDirection(objectEvent->facingDirection);
for (i = 0; i < ARRAY_COUNT(fallbackDirs); i++)
{
u8 direction = fallbackDirs[i];
s16 testX = objectEvent->currentCoords.x;
s16 testY = objectEvent->currentCoords.y;
if (direction == DIR_NONE)
continue;
MoveCoords(direction, &testX, &testY);
if (GetCollisionAtCoords(objectEvent, testX, testY, direction) == COLLISION_NONE)
return ObjectEventSetHeldMovement(objectEvent, GetWalkNormalMovementAction(direction));
}
ObjectEventTurn(objectEvent, GetMoveDirectionTowardTarget(objectEvent->currentCoords.x, objectEvent->currentCoords.y, targetX, targetY, objectEvent->facingDirection));
return FALSE;
}
static void SpawnOrSyncChasers(void)
{
u8 i;
u8 playerObjectEventId;
u8 activeChasers = ChaseStamina_GetActiveChasers();
s16 playerX = gSaveBlock1Ptr->pos.x;
s16 playerY = gSaveBlock1Ptr->pos.y;
if (TryGetObjectEventIdByLocalIdAndMap(LOCALID_PLAYER, 0, 0, &playerObjectEventId))
{
playerX = gObjectEvents[playerObjectEventId].currentCoords.x;
playerY = gObjectEvents[playerObjectEventId].currentCoords.y;
}
if (activeChasers > CHASE_OVERWORLD_MAX_CHASERS)
activeChasers = CHASE_OVERWORLD_MAX_CHASERS;
if (!sChasersSpawned)
{
sChasersSpawned = TRUE;
sSpawnedMapGroup = gSaveBlock1Ptr->location.mapGroup;
sSpawnedMapNum = gSaveBlock1Ptr->location.mapNum;
}
for (i = 0; i < CHASE_OVERWORLD_MAX_CHASERS; i++)
{
u8 localId = CHASE_OVERWORLD_LOCAL_ID_BASE + i;
u8 objectEventId;
if (i >= activeChasers)
{
RemoveObjectEventByLocalIdAndMap(localId, sSpawnedMapNum, sSpawnedMapGroup);
continue;
}
if (!TryGetObjectEventIdByLocalIdAndMap(localId, sSpawnedMapNum, sSpawnedMapGroup, &objectEventId))
{
s16 spawnX = playerX - (i + 2);
s16 spawnY = playerY + 1;
u8 elevation = MapGridGetElevationAt(spawnX, spawnY);
SpawnSpecialObjectEventParameterized(CHASE_OVERWORLD_GFX_ID, MOVEMENT_TYPE_FACE_DOWN, localId, spawnX + MAP_OFFSET, spawnY + MAP_OFFSET, elevation);
if (!TryGetObjectEventIdByLocalIdAndMap(localId, sSpawnedMapNum, sSpawnedMapGroup, &objectEventId))
continue;
}
if (ObjectEventIsHeldMovementActive(&gObjectEvents[objectEventId]))
{
if (!ObjectEventClearHeldMovementIfFinished(&gObjectEvents[objectEventId]))
continue;
}
TryQueueChaserStep(&gObjectEvents[objectEventId], playerX, playerY);
}
}
void ChaseOverworld_UpdateOverworldFrame(bool8 tookStep)
{
(void)tookStep;
if (!ChaseStamina_IsChaseActive())
{
if (sChasersSpawned)
DespawnChasers();
return;
}
if (sChasersSpawned && !IsSpawnContextValid())
{
DespawnChasers();
return;
}
SpawnOrSyncChasers();
}
void ChaseOverworld_OnMapTransition(const struct WarpData *from, const struct WarpData *to)
{
(void)from;
(void)to;
if (sChasersSpawned)
DespawnChasers();
}
void ChaseOverworld_OnChaseEnded(void)
{
if (sChasersSpawned)
DespawnChasers();
}

View File

@ -1,5 +1,6 @@
#include "global.h"
#include "battle.h"
#include "chase_overworld.h"
#include "chase_stamina.h"
#include "fieldmap.h"
#include "field_player_avatar.h"
@ -143,6 +144,7 @@ static void EndChase(void)
sActiveChasers = 0;
sChaseStepsRemaining = 0;
sChaseReengageStepCountdown = 0;
ChaseOverworld_OnChaseEnded();
}
static void StartChase(u8 initialChasers, u16 initialSteps)

View File

@ -1,6 +1,7 @@
#include "global.h"
#include "gflib.h"
#include "bike.h"
#include "chase_overworld.h"
#include "chase_stamina.h"
#include "coord_event_weather.h"
#include "daycare.h"
@ -205,6 +206,7 @@ int ProcessPlayerFieldInput(struct FieldInput *input)
FieldClearPlayerInput(&gFieldInputRecord);
gFieldInputRecord.dpadDirection = input->dpadDirection;
ChaseStamina_UpdateOverworldFrame(input->tookStep);
ChaseOverworld_UpdateOverworldFrame(input->tookStep);
if (CheckForTrainersWantingBattle() == TRUE)
return TRUE;

View File

@ -2,6 +2,7 @@
#include "gflib.h"
#include "bg_regs.h"
#include "cable_club.h"
#include "chase_overworld.h"
#include "chase_stamina.h"
#include "credits.h"
#include "corpse_run.h"
@ -796,6 +797,7 @@ void LoadMapFromCameraTransition(u8 mapGroup, u8 mapNum)
ApplyCurrentWarp();
to = gSaveBlock1Ptr->location;
ChaseStamina_OnMapTransition(&from, &to);
ChaseOverworld_OnMapTransition(&from, &to);
LoadCurrentMapData();
LoadObjEventTemplatesFromHeader();
TrySetMapSaveWarpStatus();
@ -832,6 +834,7 @@ static void LoadMapFromWarp(bool32 unused)
LoadCurrentMapData();
ChaseStamina_OnMapTransition(&from, &gSaveBlock1Ptr->location);
ChaseOverworld_OnMapTransition(&from, &gSaveBlock1Ptr->location);
LoadObjEventTemplatesFromHeader();
isOutdoors = IsMapTypeOutdoors(gMapHeader.mapType);