// 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 #include #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; } }