368 lines
12 KiB
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;
|
|
}
|
|
}
|