joeylib2/examples/agi/agiObj.c

368 lines
12 KiB
C

// AGI v2 animated-object table: state lifecycle + per-cycle ticks.
//
// The VM stores an AgiObjectT[16] inside AgiVmT; opcode handlers in
// agiVm.c mutate fields directly (set.view / position / step.size /
// etc.) and the host renders from the table each frame. This file
// owns the housekeeping the dispatcher would otherwise duplicate
// across many opcodes:
//
// * reset on construction and on NEW_ROOM
// * per-cycle cel cycling honoring start/stop.cycling, end.of.loop
// * per-cycle motion: normal / wander / follow.ego / move.obj
//
// Every algorithm here is a re-implementation against the published
// AGI v2 actor-cycle / actor-motion spec; no third-party source is
// referenced.
#include "agi.h"
#include "joey/core.h"
#include "joey/debug.h"
#include <stddef.h>
#include <string.h>
#ifdef JOEYLIB_PLATFORM_IIGS
segment "AGIOBJ";
#endif
// AGI direction encoding. 0 means "no motion this cycle"; 1..8
// circle clockwise starting at north.
// 1=N 2=NE 3=E 4=SE
// 5=S 6=SW 7=W 8=NW
#define AGI_DIR_NONE 0u
#define AGI_DIR_N 1u
#define AGI_DIR_NE 2u
#define AGI_DIR_E 3u
#define AGI_DIR_SE 4u
#define AGI_DIR_S 5u
#define AGI_DIR_SW 6u
#define AGI_DIR_W 7u
#define AGI_DIR_NW 8u
#define AGI_DIR_COUNT 9u
#define WANDER_TICKS_MIN 4u
#define WANDER_TICKS_MAX 24u
#define MOVE_REACH_PX 2
// dx, dy per direction (subscript 0 unused; AGI directions are 1..8).
static const int8_t kDirDx[AGI_DIR_COUNT] = { 0, 0, +1, +1, +1, 0, -1, -1, -1 };
static const int8_t kDirDy[AGI_DIR_COUNT] = { 0, -1, -1, 0, +1, +1, +1, 0, -1 };
// ----- Prototypes -----
static void advanceCycle(AgiVmT *vm, uint8_t objId);
static void advanceMotion(AgiVmT *vm, uint8_t objId);
static uint8_t celCount(const AgiVmT *vm, const AgiObjectT *obj);
static uint8_t directionToward(int16_t fromX, int16_t fromY, int16_t toX, int16_t toY);
// ----- Internal helpers (alphabetical) -----
static void advanceCycle(AgiVmT *vm, uint8_t objId) {
AgiObjectT *obj;
uint8_t cels;
uint8_t next;
obj = &vm->objects[objId];
if ((obj->flags & AGI_OBJ_FLAG_ANIMATED) == 0u) {
return;
}
if ((obj->flags & AGI_OBJ_FLAG_CYCLING) == 0u) {
return;
}
// Erased objects don't cycle. KQ3 LOGIC.45 sparkle pattern depends
// on this: erase(1..4) at wizard-scene start leaves their CYCLING
// flag set (LOGIC.45 never explicitly stops it), but Sierra's
// interpreter freezes the cel until the next draw(). Without this
// gate, end.of.loop fires for invisible objs, sets flag 221..224,
// and the sparkle loop kicks back in on the wizard pic.
if ((obj->flags & AGI_OBJ_FLAG_DRAWN) == 0u) {
return;
}
// stop.update freezes the obj's displayed cel until start.update.
// Canonical AGI cycles cels internally regardless, but with no
// display refresh the visual stays put. Since our host renderer
// always paints from obj->cel, the easiest faithful approximation
// is to also stop advancing cel while !UPDATING -- otherwise
// poses cycle visibly (e.g. Manannan's body in room 46, which is
// stop.update'd at init and only released to start.update at a
// specific state, would otherwise wave its hand continuously).
if ((obj->flags & AGI_OBJ_FLAG_UPDATING) == 0u) {
return;
}
if (obj->cycleTime == 0u) {
return;
}
obj->cycleTick++;
if (obj->cycleTick < obj->cycleTime) {
return;
}
obj->cycleTick = 0u;
cels = celCount(vm, obj);
if (cels <= 1u) {
return;
}
switch (obj->cycleMode) {
case AGI_CYCLE_NORMAL:
next = (uint8_t)((obj->cel + 1u) % cels);
obj->cel = next;
break;
case AGI_CYCLE_REVERSE:
obj->cel = (obj->cel == 0u) ? (uint8_t)(cels - 1u) : (uint8_t)(obj->cel - 1u);
break;
case AGI_CYCLE_END_LOOP:
if (obj->cel + 1u >= cels) {
// reached last frame, fire end-of-loop
obj->flags &= (uint8_t)~AGI_OBJ_FLAG_CYCLING;
if (obj->endOfLoopFlag != 0u) {
vm->flags[obj->endOfLoopFlag] = 1u;
}
} else {
obj->cel++;
}
break;
case AGI_CYCLE_REV_LOOP:
if (obj->cel == 0u) {
obj->flags &= (uint8_t)~AGI_OBJ_FLAG_CYCLING;
if (obj->endOfLoopFlag != 0u) {
vm->flags[obj->endOfLoopFlag] = 1u;
}
} else {
obj->cel--;
}
break;
default:
break;
}
}
static void advanceMotion(AgiVmT *vm, uint8_t objId) {
AgiObjectT *obj;
int16_t newX;
int16_t newY;
uint8_t dir;
int8_t dx;
int8_t dy;
obj = &vm->objects[objId];
if ((obj->flags & AGI_OBJ_FLAG_ANIMATED) == 0u) {
return;
}
if ((obj->flags & AGI_OBJ_FLAG_DRAWN) == 0u) {
return;
}
if (obj->stepSize == 0u) {
return;
}
if (obj->stepTime == 0u) {
return;
}
obj->stepTick++;
if (obj->stepTick < obj->stepTime) {
return;
}
obj->stepTick = 0u;
switch (obj->motionType) {
case AGI_MOTION_WANDER:
if (obj->wanderTick == 0u) {
obj->direction = (uint8_t)(jlRandomRange((uint16_t)AGI_DIR_COUNT));
obj->wanderTick = (uint8_t)(WANDER_TICKS_MIN + jlRandomRange((uint16_t)(WANDER_TICKS_MAX - WANDER_TICKS_MIN)));
} else {
obj->wanderTick--;
}
break;
case AGI_MOTION_FOLLOW_EGO:
obj->direction = directionToward(obj->x, obj->y, vm->objects[0].x, vm->objects[0].y);
break;
case AGI_MOTION_MOVE_OBJ: {
int16_t reachDx;
int16_t reachDy;
reachDx = (int16_t)(obj->targetX - obj->x);
reachDy = (int16_t)(obj->targetY - obj->y);
if (reachDx < 0) {
reachDx = (int16_t)(-reachDx);
}
if (reachDy < 0) {
reachDy = (int16_t)(-reachDy);
}
if (reachDx <= MOVE_REACH_PX && reachDy <= MOVE_REACH_PX) {
obj->x = obj->targetX;
obj->y = obj->targetY;
obj->direction = AGI_DIR_NONE;
obj->motionType = AGI_MOTION_NORMAL;
obj->stepSize = obj->moveStepBackup;
if (obj->moveDoneFlag != 0u) {
vm->flags[obj->moveDoneFlag] = 1u;
}
return;
}
obj->direction = directionToward(obj->x, obj->y, obj->targetX, obj->targetY);
break;
}
case AGI_MOTION_NORMAL:
default:
// Direction is whatever the script (or var 6 for ego) set.
break;
}
dir = obj->direction;
if (dir == AGI_DIR_NONE || dir >= AGI_DIR_COUNT) {
return;
}
dx = kDirDx[dir];
dy = kDirDy[dir];
newX = (int16_t)(obj->x + (int16_t)((int16_t)dx * (int16_t)obj->stepSize));
newY = (int16_t)(obj->y + (int16_t)((int16_t)dy * (int16_t)obj->stepSize));
// Clamp to AGI's canonical 160x168 picture window. The horizon
// applies only to objects observing it.
if (newX < 0) {
newX = 0;
}
if (newX > (int16_t)(AGI_PIC_WIDTH - 1)) {
newX = (int16_t)(AGI_PIC_WIDTH - 1);
}
if (newY > (int16_t)(AGI_PIC_HEIGHT - 1)) {
newY = (int16_t)(AGI_PIC_HEIGHT - 1);
}
if ((obj->flags & AGI_OBJ_FLAG_HORIZON_IGN) == 0u && newY < (int16_t)vm->horizon) {
newY = (int16_t)vm->horizon;
}
if (newY < 0) {
newY = 0;
}
obj->x = newX;
obj->y = newY;
}
// Without a view-id-to-loop-cel-count map in the VM, fall back to an
// Asks the host for the actual cel count of obj's view+loop via
// the viewCelCount callback. Falls back to AGI_MAX_CELS_PER_LOOP
// only when no callback is wired (legacy host) -- but cycling
// is broken in that mode because end.of.loop won't fire at the
// correct cel. A 0 return from the callback means "view not
// loaded yet"; we report 0 and the caller's `cels <= 1u` guard
// pauses cycling until the load completes.
static uint8_t celCount(const AgiVmT *vm, const AgiObjectT *obj) {
if (vm->callbacks.viewCelCount != NULL) {
return vm->callbacks.viewCelCount(vm->callbacks.ctx, obj->viewId, obj->loop);
}
return AGI_MAX_CELS_PER_LOOP;
}
// 8-direction snap by quadrant + axis-dominance.
static uint8_t directionToward(int16_t fromX, int16_t fromY, int16_t toX, int16_t toY) {
int16_t dx;
int16_t dy;
int16_t ax;
int16_t ay;
dx = (int16_t)(toX - fromX);
dy = (int16_t)(toY - fromY);
if (dx == 0 && dy == 0) {
return AGI_DIR_NONE;
}
ax = (dx < 0) ? (int16_t)(-dx) : dx;
ay = (dy < 0) ? (int16_t)(-dy) : dy;
if (ax > ay * 2) {
return (dx > 0) ? AGI_DIR_E : AGI_DIR_W;
}
if (ay > ax * 2) {
return (dy > 0) ? AGI_DIR_S : AGI_DIR_N;
}
if (dx > 0) {
return (dy > 0) ? AGI_DIR_SE : AGI_DIR_NE;
}
return (dy > 0) ? AGI_DIR_SW : AGI_DIR_NW;
}
// ----- Public API (alphabetical) -----
void agiObjReset(AgiObjectT *obj) {
memset(obj, 0, sizeof(*obj));
obj->stepSize = 1u;
obj->stepTime = 1u;
obj->cycleTime = 1u;
obj->priority = 0u; // 0 = "auto-Y-band" (canonical AGI)
obj->direction = AGI_DIR_NONE;
obj->cycleMode = AGI_CYCLE_NORMAL;
obj->motionType = AGI_MOTION_NORMAL;
obj->endOfLoopFlag = 0u;
obj->moveDoneFlag = 0u;
obj->followDoneFlag = 0u;
}
void agiObjResetAll(AgiVmT *vm) {
uint8_t i;
for (i = 0u; i < (uint8_t)AGI_MAX_OBJECTS; i++) {
agiObjReset(&vm->objects[i]);
}
}
// Called by VM core after each VM cycle returns to the host (or by
// the host directly each game-loop cycle). Walks the object table
// and advances cycling + motion. Also polls the host for sound
// completion so the sound-done flag fires on the natural-end of
// playback rather than a pre-computed deadline.
void agiVmTickAnimation(AgiVmT *vm) {
uint8_t i;
// Sound poll: fire done-flags on the host's playback->silent
// edge. Runs every VM cycle so OP_IF wait loops see the flag
// within one cycle of actual audio end.
if (vm->soundPlaying && vm->callbacks.isPlayingSound != NULL) {
if (!vm->callbacks.isPlayingSound(vm->callbacks.ctx)) {
jlLogF("sound-done flag fires: flag=%u (v11=%u)",
(unsigned)vm->soundDoneFlag, (unsigned)vm->vars[11]);
jlLogFlush();
if (vm->soundDoneFlag != 0u) {
vm->flags[vm->soundDoneFlag] = 1u;
}
vm->flags[9] = 1u;
vm->soundPlaying = false;
}
}
for (i = 0u; i < (uint8_t)AGI_MAX_OBJECTS; i++) {
AgiObjectT *obj;
obj = &vm->objects[i];
if ((obj->flags & AGI_OBJ_FLAG_ANIMATED) == 0u) {
continue;
}
// Snapshot current state so the host renderer can restore the
// last frame's pixels under each obj before re-drawing this
// frame. Done before the tick so prevX/prevY reflect what was
// actually painted last frame.
obj->prevX = obj->x;
obj->prevY = obj->y;
obj->prevLoop = obj->loop;
obj->prevCel = obj->cel;
obj->prevView = obj->viewId;
advanceCycle(vm, i);
advanceMotion(vm, i);
}
// Mirror ego state into AGI's well-known engine vars so game
// scripts that probe v6 (ego dir) / current.view / get.posn
// see the right values without a per-opcode getter call.
if ((vm->objects[0].flags & AGI_OBJ_FLAG_ANIMATED) != 0u) {
vm->vars[6] = vm->objects[0].direction;
}
}