[Scummvm-git-logs] scummvm master -> cb68a7306d812033f33e94b0058964c1c898df74

sev- noreply at scummvm.org
Tue Jul 28 12:05:37 UTC 2026


This automated email contains information about 2 new commits which have been
pushed to the 'scummvm' repo located at https://api.github.com/repos/scummvm/scummvm .

Summary:
29a7c974c2 AGDS: Implement character walking and fix sprite rendering
cb68a7306d AGDS: Don't restore character state from script-created screen patches


Commit: 29a7c974c20e5c447503eaa2396d33bcfcde5bff
    https://github.com/scummvm/scummvm/commit/29a7c974c20e5c447503eaa2396d33bcfcde5bff
Author: nikaiw (devillers.nicolas at gmail.com)
Date: 2026-07-28T14:05:32+02:00

Commit Message:
AGDS: Implement character walking and fix sprite rendering

Character animations are stored as direction * 5 + walk phase; using
raw directions as indices kept the character stuck facing north.
Sprites are now anchored and scaled from the per-frame canvas metadata
instead of scanning pixels, which fixes wrong sizes on gestures and
between facing directions.

Walking follows the original engine: paths are built inside the
screen's walk region with detours around obstacle polygons, the
character turns gradually, and each step is driven by the animation's
per-frame displacements with an error correction that lands exactly on
the target.

Changed paths:
    engines/agds/agds.cpp
    engines/agds/character.cpp
    engines/agds/character.h
    engines/agds/process_opcodes.cpp
    engines/agds/region.cpp
    engines/agds/region.h


diff --git a/engines/agds/agds.cpp b/engines/agds/agds.cpp
index e22cbdeb9eb..e7e18037866 100644
--- a/engines/agds/agds.cpp
+++ b/engines/agds/agds.cpp
@@ -316,7 +316,7 @@ void AGDSEngine::saveScreenPatch() {
 	patch->characterPresent = _currentCharacter != nullptr && _currentCharacter->visible();
 	if (_currentCharacter) {
 		patch->characterPosition = _currentCharacter->position();
-		patch->characterDirection = _currentCharacter->direction();
+		patch->characterDirection = _currentCharacter->baseDirection();
 	}
 	patch->defaultMouseCursor = _defaultMouseCursorName;
 }
@@ -657,10 +657,8 @@ Common::Error AGDSEngine::run() {
 						if (lclick) {
 							if (_currentCharacter && _currentCharacter->active() && _currentScreen && _currentScreen->region()) {
 								auto &region = _currentScreen->region();
-								if (region->pointIn(_mouse)) {
-									// FIXME: some object requires character to be in "trap" region
-									// Remove this after movement implementation.
-									_currentCharacter->moveTo(Common::String(), _mouse, -1);
+								if (region->walkPointInside(_mouse) > 0) {
+									_currentCharacter->moveTo(Common::String(), _mouse, -1, true);
 								}
 							}
 							auto scroll = _currentScreen->scrollPosition();
diff --git a/engines/agds/character.cpp b/engines/agds/character.cpp
index 26b8980fb89..b19955b6775 100644
--- a/engines/agds/character.cpp
+++ b/engines/agds/character.cpp
@@ -32,13 +32,22 @@
 #include "common/textconsole.h"
 #include "common/util.h"
 #include "graphics/managed_surface.h"
+#include <math.h>
 
 namespace AGDS {
 
 Character::Character(AGDSEngine *engine, const Common::String &name) : _engine(engine), _object(), _animation(nullptr), _jokes(false),
 																	   _name(name),
 																	   _enabled(true), _visible(false), _stopped(false), _shown(false),
-																	   _phase(-1), _frames(0), _direction(-1), _movementDirections(0) {
+																	   _phase(-1), _frames(0), _direction(-1), _movementDirections(0),
+																	   _description(nullptr), _standing(false),
+																	   _walkState(kWalkIdle), _targetDir(-1), _pathIndex(0),
+																	   _finalDir(-1), _exactMove(false), _walkPhase(0),
+																	   _cyclesA(0), _cyclesB(0), _playStopAnim(false), _scaleIdx(0),
+																	   _errX(0), _errY(0), _errStepX(0), _errStepY(0),
+																	   _errPeriodX(1), _errPeriodY(1), _errPhaseX(0), _errPhaseY(0),
+																	   _walkTickCnt(0), _cycleErrX(0), _cycleErrY(0), _corrX(0), _corrY(0),
+																	   _decodedFrame(0) {
 }
 
 Character::~Character() {
@@ -77,27 +86,24 @@ void Character::load(Common::SeekableReadStream &stream) {
 			int y = stream.readSint16LE();
 			uint w = stream.readUint32LE();
 			uint h = stream.readUint32LE();
-			AnimationDescription::Frame frame = {x, y, w, h};
-			animation.frames.push_back(frame);
-			debug("frame %d, %d, %dx%d", x, y, w, h);
-			uint unk1 = stream.readUint32LE();
-			uint unk2 = stream.readUint32LE();
-			uint unk3 = stream.readUint32LE();
-			uint unk4 = stream.readUint32LE(); // GRP file offset?
-			uint unk5 = stream.readUint32LE();
-			uint unk6 = stream.readByte();
-			uint unk7 = stream.readUint32LE();
-			uint unk8 = stream.readUint32LE();
+			stream.readUint32LE(); // bit depth
+			int cropX = stream.readSint32LE(); // position of the crop inside the FLIC frame
+			int cropY = stream.readSint32LE();
+			stream.readUint32LE(); // in-memory pixel pointer slot
+			stream.readUint32LE(); // flag byte + palette pointer slot (packed)
+			stream.readByte();
+			uint canvasW = stream.readUint32LE(); // logical canvas width
+			uint canvasH = stream.readUint32LE(); // logical canvas height
 			stream.readUint32LE(); // CDCDCDCD
-			uint unk9 = stream.readUint32LE();
-			uint unk10 = stream.readUint32LE();
+			int offX = stream.readSint32LE(); // anchor tweak inside canvas
+			int offY = stream.readSint32LE();
 			stream.readUint32LE(); // CDCDCDCD
 			uint unk11 = stream.readByte();
 			stream.readUint32LE(); // CDCDCDCD
-			debug("unknown: %u %u %u 0x%08x - %u %u %u %u - %u %u %u",
-				  unk1, unk2, unk3, unk4,
-				  unk5, unk6, unk7, unk8,
-				  unk9, unk10, unk11);
+			AnimationDescription::Frame frame = {x, y, w, h, canvasW, canvasH, offX, offY, cropX, cropY};
+			animation.frames.push_back(frame);
+			debug("frame %d, %d, %dx%d canvas %ux%u off %d,%d crop %d,%d (unk %u)",
+				  x, y, w, h, canvasW, canvasH, offX, offY, cropX, cropY, unk11);
 		}
 		_animations[index] = animation;
 	}
@@ -136,13 +142,23 @@ void Character::saveState(Common::WriteStream &stream) const {
 
 bool Character::direction(int dir) {
 	debug("setDirection %d", dir);
-	_direction = dir;
 
-	if (dir < 0)
+	if (dir == -1) {
+		// -1 sentinel from the game data means "keep current direction":
+		// do nothing, do not touch _direction, do not swap animation.
 		return false;
+	}
 
-	_animationPos = Common::Point();
-	return animate(dir, 100, false);
+	// out-of-range directions fall back to 8 (facing camera), like the original
+	if (dir < 0 || dir > 15) {
+		warning("Character %s: direction %d out of range, defaulting to 8",
+		        _object ? _object->getName().c_str() : "?", dir);
+		dir = 8;
+	}
+
+	_direction = dir;
+	// idle facing shows phase 0 frame 0 (the standing pose)
+	return animate(animIndexFor(dir, 0), 100, false);
 }
 
 void Character::notifyProcess(const Common::String &name) {
@@ -153,42 +169,719 @@ void Character::notifyProcess(const Common::String &name) {
 	_processName = name;
 }
 
-bool Character::moveTo(const Common::String &processName, Common::Point dst, int dir) {
-	if (!_visible)
+// --- Walking (faithful reimplementation of the original state machine) ---
+
+static inline double wDistSq(Common::Point a, Common::Point b) {
+	double dx = a.x - b.x, dy = a.y - b.y;
+	return dx * dx + dy * dy;
+}
+
+double Character::scalePermyriad(int v, int scale) {
+	double r = (double)v * scale / 10000.0;
+	if (r > -0.1 && r < 0.1)
+		r = (r >= 0) ? 0.1 : -0.1;
+	return r;
+}
+
+int Character::perspectiveScalePermyriad(int y) const {
+	auto screen = _engine->getCurrentScreen();
+	if (!screen)
+		return 10000;
+	return (int)(screen->getZScale(y) * 10000.0f + 0.5f);
+}
+
+int Character::scaleForSegmentY(int simY, int targetY) const {
+	int y;
+	if (_direction == 4 || _direction == 12)
+		y = _pos.y;                                    // horizontal
+	else if (_direction <= 3 || _direction >= 13)
+		y = MAX(simY, targetY);                        // walking up
+	else
+		y = MIN(simY, targetY);                        // walking down
+	return perspectiveScalePermyriad(y);
+}
+
+Common::Point Character::totalDisp(int dir, int phase) const {
+	const AnimationDescription *desc = animationDescription(animIndexFor(dir, phase));
+	if (!desc || desc->frames.empty())
+		return Common::Point();
+	const AnimationDescription::Frame &f = desc->frames[desc->frames.size() - 1];
+	return Common::Point(f.x, f.y);
+}
+
+void Character::checkTrapRegions() {
+	auto *screen = _engine->getCurrentScreen();
+	if (!screen)
+		return;
+	auto objects = screen->find(_pos);
+	for (auto &object : objects) {
+		auto region = object->getTrapRegion();
+		if (region && region->pointIn(_pos)) {
+			debug("starting trap process");
+			_engine->runProcess(object, object->getTrapHandler());
+		}
+	}
+}
+
+bool Character::loadWalkAnim() {
+	int index = animIndexFor(_direction, _walkPhase < 0 ? 0 : _walkPhase);
+	const AnimationDescription *desc = animationDescription(index);
+	if (!desc)
 		return false;
+	if (_description != desc || !_animation) {
+		auto animation = _engine->loadAnimation(desc->filename);
+		if (!animation) {
+			warning("no walk animation file %s", desc->filename.c_str());
+			return false;
+		}
+		_description = desc;
+		_animation = animation;
+		_frames = desc->frames.size();
+		_decodedFrame = 0;
+		_jokes = false;
+		_standing = true; // frames are driven by the walk machine, not Animation::tick
+		_animation->scale(perspectiveScale());
+	}
+	if (_phase < _decodedFrame) {
+		_animation->rewind();
+		_animation->decodeNextFrame();
+		_decodedFrame = 0;
+	}
+	while (_decodedFrame < _phase && _decodedFrame + 1 < (int)_description->frames.size()) {
+		_animation->decodeNextFrame();
+		_decodedFrame++;
+	}
+	return true;
+}
+
+int Character::advanceWalkPhase(bool cont) {
+	switch (_walkPhase) {
+	case 0:
+		if (_cyclesA == 0 && !cont)
+			_walkPhase = _playStopAnim ? 3 : -1;
+		else {
+			if (_cyclesA > 0)
+				_cyclesA--;
+			_walkPhase = 1;
+		}
+		break;
+	case 1:
+		if (_cyclesB == 0 && !cont)
+			_walkPhase = _playStopAnim ? 4 : -1;
+		else {
+			if (_cyclesB > 0)
+				_cyclesB--;
+			_walkPhase = 2;
+		}
+		break;
+	case 2:
+		if (_cyclesA == 0 && !cont)
+			_walkPhase = _playStopAnim ? 3 : -1;
+		else {
+			if (_cyclesA > 0)
+				_cyclesA--;
+			_walkPhase = 1;
+		}
+		break;
+	default:
+		_walkPhase = cont ? 0 : -1;
+		break;
+	}
+	return _walkPhase;
+}
+
+void Character::setCycleScale(uint idx, int scale) {
+	while (_cycleScales.size() <= idx)
+		_cycleScales.push_back(scale);
+	_cycleScales[idx] = scale;
+}
+
+void Character::setupSegmentPhased(int dx, int dy) {
+	bool firstSeg = (_pathIndex == 1);
+	bool playStart = firstSeg && _walkPhase == 0;
+	bool lastWaypoint = (_pathIndex + 1 >= _path.size());
+	_playStopAnim = lastWaypoint && _walkState != kWalkLeaving;
+
+	int scale0 = perspectiveScalePermyriad(_pos.y);
+	_cycleScales.clear();
+	_cycleScales.push_back(scale0);
+
+	Common::Point dispA = totalDisp(_direction, 1);
+	Common::Point dispB = totalDisp(_direction, 2);
+	Common::Point dispS;
+	if (playStart)
+		dispS = totalDisp(_direction, 0);
+	else if (!firstSeg)
+		advanceWalkPhase(true); // keep the A/B alternation going across waypoints
+	Common::Point dispS3 = _playStopAnim ? totalDisp(_direction, 3) : Common::Point();
+	Common::Point dispS4 = _playStopAnim ? totalDisp(_direction, 4) : Common::Point();
+
+	int sgnX = dx < 0 ? -1 : 1, adx = ABS(dx);
+	int sgnY = dy < 0 ? -1 : 1, ady = ABS(dy);
+	Common::Point target(_pos.x + sgnX * adx, _pos.y + sgnY * ady);
+
+	int curPhase = _walkPhase ? _walkPhase : 1;
+	double simX = _pos.x, simY = _pos.y;
+	uint idx = 0;
+	if (playStart) {
+		simX += (int)scalePermyriad(dispS.x, scale0);
+		simY += (int)scalePermyriad(dispS.y, scale0);
+		idx = 1; // scale[0] is consumed by the start animation
+	}
+	int sc = scaleForSegmentY((int)simY, target.y);
+	setCycleScale(idx++, sc);
 
-	debug("character move %d,%d %d", dst.x, dst.y, dir);
+	auto endWithStop = [&](int cp, int scl) -> Common::Point {
+		Common::Point e((int16)simX, (int16)simY);
+		if (_playStopAnim) {
+			Common::Point d = (cp == 1) ? dispS3 : dispS4;
+			e.x += (int)scalePermyriad(d.x, scl);
+			e.y += (int)scalePermyriad(d.y, scl);
+		}
+		return e;
+	};
+
+	Common::Point end = endWithStop(curPhase, sc);
+	double best = wDistSq(end, target);
+	_cyclesA = _cyclesB = 0;
+	Common::Point prevEnd = end;
+	double prevBest = best;
+	for (int iter = 0; iter < 100; ++iter) {
+		prevEnd = end;
+		prevBest = best;
+		if (curPhase == 1) {
+			simX += (int)scalePermyriad(dispA.x, sc);
+			simY += (int)scalePermyriad(dispA.y, sc);
+			curPhase = 2;
+			_cyclesA++;
+		} else {
+			simX += (int)scalePermyriad(dispB.x, sc);
+			simY += (int)scalePermyriad(dispB.y, sc);
+			curPhase = 1;
+			_cyclesB++;
+		}
+		sc = scaleForSegmentY((int)simY, target.y);
+		setCycleScale(idx++, sc);
+		end = endWithStop(curPhase, sc);
+		best = wDistSq(end, target);
+		if (best >= prevBest)
+			break;
+	}
+	_scaleIdx = 0;
+	// last simulated cycle overshot: retract it (curPhase already flipped)
+	if (curPhase == 1) {
+		if (_cyclesB > 0)
+			_cyclesB--;
+	} else {
+		if (_cyclesA > 0)
+			_cyclesA--;
+	}
+	// no start, no stop, zero cycles: force one cycle, keep the overshoot end
+	if (!playStart && !_playStopAnim && !_cyclesA && !_cyclesB) {
+		prevEnd = end;
+		if (curPhase == 1)
+			_cyclesB++;
+		else
+			_cyclesA++;
+	}
+	// --- error budget: distribute the difference between the animation's
+	// natural end point and the exact waypoint across the walk frames ---
+	auto frameCountOf = [&](int phase) -> int {
+		const AnimationDescription *d = animationDescription(animIndexFor(_direction, phase));
+		return (d && !d->frames.empty()) ? (int)d->frames.size() : 1;
+	};
+	int stopIdx = (_cyclesA > _cyclesB) ? 4 : 3;
+	int extra = _playStopAnim ? frameCountOf(stopIdx) - (2 * frameCountOf(stopIdx)) / 10 : 0;
+	int plannedDX = prevEnd.x - _pos.x;
+	int plannedDY = prevEnd.y - _pos.y;
+	_errX = sgnX * adx - plannedDX;
+	_errY = sgnY * ady - plannedDY;
+	if (_exactMove && _playStopAnim && playStart) {
+		if (!_cyclesA && !_cyclesB && _errX * sgnX < 0) {
+			// planned end overshoots even with zero cycles: just turn
+			_walkState = kWalkTurning;
+			return;
+		}
+	}
+	if (playStart)
+		extra += frameCountOf(0) - (2 * frameCountOf(0)) / 10 - 1;
+	int total = _cyclesB * (frameCountOf(2) - 1) + extra + _cyclesA * (frameCountOf(1) - 1);
+	if (total <= 0)
+		total = 1;
+	_errStepX = _errX / total;
+	_errX %= total;
+	_errStepY = _errY / total;
+	_errY %= total;
+	_errPeriodX = _errX ? total / ABS(_errX) : 1;
+	_errPeriodY = _errY ? total / ABS(_errY) : 1;
+	if (_errPeriodX <= 0)
+		_errPeriodX = 1;
+	if (_errPeriodY <= 0)
+		_errPeriodY = 1;
+	_errPhaseX = (total - _errPeriodX * ABS(_errX)) / 2;
+	_errPhaseY = (total - _errPeriodY * ABS(_errY)) / 2;
+
+	// the in-progress cycle consumes one planned cycle, or, with nothing
+	// planned at all, we skip straight to the stop animation
+	if (_playStopAnim && !playStart && !_cyclesA && !_cyclesB)
+		_walkPhase = (_walkPhase == 1) ? 3 : 4;
+	else if (_walkPhase == 1) {
+		if (_cyclesA > 0)
+			_cyclesA--;
+	} else if (_walkPhase == 2) {
+		if (_cyclesB > 0)
+			_cyclesB--;
+	}
+}
+
+void Character::startNextSegment() {
+	if (_pathIndex >= _path.size()) {
+		_walkState = kWalkIdle;
+		return;
+	}
+	_segTarget = _path[_pathIndex];
+	int dx = _segTarget.x - _pos.x;
+	int dy = _segTarget.y - _pos.y;
+	_segStart = _pos;
+	_cycleErrX = _cycleErrY = _corrX = _corrY = 0;
+	_walkTickCnt = 0;
+
+	int oldDir = _direction;
+	_direction = getDirectionForMovement(Common::Point(dx, dy));
+	if (phased())
+		setupSegmentPhased(dx, dy);
+	_pathIndex++;
+	_targetDir = _direction;
+	_direction = oldDir; // keep facing the old way until the turn completes
+}
+
+void Character::stepErrorCorrection() {
+	auto frameCountOf = [&](int phase) -> int {
+		const AnimationDescription *d = animationDescription(animIndexFor(_direction, phase));
+		return (d && !d->frames.empty()) ? (int)d->frames.size() : 1;
+	};
+	// skip the first 20% of the start anim and the last 20% of stop anims
+	if (_walkPhase == 0 && _phase < (2 * frameCountOf(0)) / 10)
+		return;
+	if (_walkPhase >= 3) {
+		int c = frameCountOf(_walkPhase);
+		if (_phase >= c - (2 * c) / 10)
+			return;
+	}
+	if (_walkTickCnt >= _errPhaseX && _errX != 0 && _walkTickCnt % _errPeriodX == 0) {
+		if (_errX > 0) {
+			_corrX++;
+			_errX--;
+		} else {
+			_corrX--;
+			_errX++;
+		}
+	}
+	if (_walkTickCnt >= _errPhaseY && _errY != 0 && _walkTickCnt % _errPeriodY == 0) {
+		if (_errY > 0) {
+			_corrY++;
+			_errY--;
+		} else {
+			_corrY--;
+			_errY++;
+		}
+	}
+	_walkTickCnt++;
+	_corrX += _errStepX;
+	_corrY += _errStepY;
+}
+
+void Character::advanceWalkFrame() {
+	_phase++;
+	const AnimationDescription *desc = animationDescription(animIndexFor(_direction, _walkPhase < 0 ? 0 : _walkPhase));
+	int count = (desc && !desc->frames.empty()) ? (int)desc->frames.size() : 1;
+	if (_phase >= count) {
+		auto screen = _engine->getCurrentScreen();
+		RegionPtr region = screen ? screen->region() : RegionPtr();
+		bool pending = _walkState == kWalkRemovePending || _walkState == kWalkLeavePending ||
+		               _walkState == kWalkStopPending;
+		if (pending && region && region->walkPointInside(_pos) > 0) {
+			_walkPhase = -1;
+		} else {
+			// cycle boundary: bank the actual-vs-predicted error
+			_cycleErrX = _pos.x - _segStart.x - _corrX;
+			_cycleErrY = _pos.y - _segStart.y - _corrY;
+			advanceWalkPhase(false);
+			_scaleIdx++;
+		}
+		_phase = 1;
+	}
+}
+
+bool Character::walkTickAdvance() {
+	int savedPhase = _walkPhase;
+	advanceWalkFrame();
+	if (_walkPhase == -1) {
+		switch (_walkState) {
+		case kWalkRemovePending:
+		case kWalkLeavePending: {
+			auto screen = _engine->getCurrentScreen();
+			RegionPtr region = screen ? screen->region() : RegionPtr();
+			if (region && region->walkPointInside(_pos) > 0) {
+				_walkPhase = savedPhase;
+				return false;
+			}
+			_walkPhase = _playStopAnim ? 0 : savedPhase;
+			break;
+		}
+		case kWalkStopPending:
+			_walkPhase = 0;
+			break;
+		default:
+			_walkPhase = _playStopAnim ? 0 : savedPhase;
+			break;
+		}
+		return false;
+	}
+	stepErrorCorrection();
+	if (!loadWalkAnim())
+		return false;
+	if (_description && !_description->frames.empty()) {
+		int idx = CLIP(_phase, 0, (int)_description->frames.size() - 1);
+		const AnimationDescription::Frame &f = _description->frames[idx];
+		int sc = _cycleScales.empty()
+		             ? 10000
+		             : _cycleScales[CLIP(_scaleIdx, 0, (int)_cycleScales.size() - 1)];
+		_pos.x = _segStart.x + _corrX + _cycleErrX + (int)scalePermyriad(f.x, sc);
+		_pos.y = _segStart.y + _corrY + _cycleErrY + (int)scalePermyriad(f.y, sc);
+	}
+	return true;
+}
+
+void Character::arrive(int prevState) {
+	switch (prevState) {
+	case kWalkWalking:
+	case kWalkRemovePending:
+	case kWalkStopPending:
+		if (_finalDir != -1) {
+			// reload the standing pose before turning so the displayed
+			// frame matches the anchor again
+			_walkPhase = 0;
+			_phase = 0;
+			direction(_direction);
+			_walkState = kWalkTurning;
+			_targetDir = _finalDir;
+			return;
+		}
+		break;
+	case kWalkLeaving:
+	case kWalkLeavePending:
+		advanceWalkPhase(true);
+		break;
+	default:
+		break;
+	}
+	_walkPhase = 0;
+	_phase = 0;
+	direction(_direction); // reload the standing pose
+	checkTrapRegions();
+	// the waiting process is reactivated by tick() now that we are idle
+}
+
+void Character::walkingTick() {
+	if (walkTickAdvance())
+		return;
+
+	int prevState = _walkState;
+	bool snap = false;
+	Common::Point savedTarget;
+	auto screen = _engine->getCurrentScreen();
+	RegionPtr region = screen ? screen->region() : RegionPtr();
+	bool inside = region && region->walkPointInside(_pos) > 0;
+
+	switch (_walkState) {
+	case kWalkRemovePending:
+		if (inside) {
+			advanceWalkPhase(true);
+			_path.clear();
+			_pathIndex = 0;
+			_walkState = kWalkIdle;
+			Common::String proc = _processName;
+			_processName.clear();
+			moveTo(proc, _finalTarget, _finalDir, _exactMove);
+			return;
+		}
+		_phase = 0;
+		break;
+	case kWalkLeavePending:
+		if (inside) {
+			advanceWalkPhase(true);
+			_path.clear();
+			_pathIndex = 0;
+			_walkState = kWalkIdle;
+			Common::String proc = _processName;
+			_processName.clear();
+			moveTo(proc, _finalTarget, _finalDir, false, true);
+			return;
+		}
+		_phase = 0;
+		break;
+	case kWalkStopPending: {
+		_playStopAnim = true;
+		_cyclesA = _cyclesB = 0;
+		advanceWalkPhase(false);
+		_path.clear();
+		_pathIndex = 0;
+		_walkState = kWalkIdle;
+		Common::Point d = totalDisp(_direction, _walkPhase < 0 ? 0 : _walkPhase);
+		int sc = _cycleScales.empty() ? 10000 : _cycleScales[0];
+		Common::Point t(_pos.x + (int)scalePermyriad(d.x, sc),
+		                _pos.y + (int)scalePermyriad(d.y, sc));
+		_finalTarget = t;
+		if (region && region->walkPointInside(t) > 0) {
+			Common::String proc = _processName;
+			_processName.clear();
+			moveTo(proc, t, _finalDir);
+			return;
+		}
+		_walkPhase = 0;
+		_phase = 0;
+		break;
+	}
+	default: // kWalkWalking, kWalkLeaving
+		savedTarget = _segTarget;
+		snap = true;
+		_phase = 0;
+		break;
+	}
+
+	startNextSegment();
+	if (_walkState == kWalkIdle) {
+		if (snap)
+			_pos = savedTarget; // land exactly on the waypoint
+		arrive(prevState);
+	} else {
+		loadWalkAnim(); // resync the displayed frame with the new segment
+	}
+}
+
+void Character::turnOneStep() {
+	int step = (_movementDirections == 16) ? 1 : 2;
+	int diff = (_direction - _targetDir) & 15;
+	if (diff <= 8)
+		_direction = (_direction == 0) ? 16 - step : _direction - step;
+	else
+		_direction = (_direction == 16 - step) ? 0 : _direction + step;
+	loadWalkAnim(); // refresh the sprite in the new facing, same phase/frame
+}
+
+void Character::walkUpdateTick() {
+	if (_walkState == kWalkIdle || _jokes)
+		return;
+	// 8-direction characters can only face even directions; an odd turn
+	// target would never be reached with 2-step turns
+	if (_movementDirections == 8)
+		_targetDir &= ~1;
+	if (_direction == _targetDir) {
+		if (_walkState == kWalkTurning) {
+			_walkState = kWalkIdle;
+			_walkPhase = 0;
+			_phase = 0;
+			direction(_direction);
+		} else {
+			walkingTick();
+		}
+	} else {
+		turnOneStep();
+	}
+}
+
+void Character::stopMoving(bool force) {
+	if (_jokes) {
+		_jokes = false;
+		_phase = -1;
+		_frames = 0;
+		direction(_direction);
+		return;
+	}
+	bool deferred = (_walkState == kWalkRemovePending || _walkState == kWalkLeavePending ||
+	                 _walkState == kWalkStopPending) &&
+	                !force;
+	if (!deferred) {
+		_path.clear();
+		_pathIndex = 0;
+	}
+	if (deferred)
+		return;
+	_walkState = kWalkIdle;
+	_walkPhase = 0;
+	_phase = 0;
+	if (_finalDir >= 0 && _finalDir <= 15)
+		_direction = _finalDir;
+	else if (_direction < 0 || _direction > 15)
+		_direction = 0;
+	direction(_direction);
+}
+
+void Character::requestStop(int dir) {
+	if (_jokes) {
+		stopMoving(true);
+		if (dir != -1)
+			direction(dir);
+		return;
+	}
+	switch (_walkState) {
+	case kWalkIdle:
+		if (dir != -1)
+			direction(dir);
+		return;
+	case kWalkTurning:
+		_walkState = kWalkIdle;
+		if (dir != -1) {
+			_direction = dir;
+			direction(dir);
+		}
+		return;
+	default:
+		if (_walkPhase >= 3) // already finishing a stop animation
+			return;
+		_walkState = kWalkStopPending;
+		_finalDir = dir;
+		return;
+	}
+}
+
+bool Character::moveTo(const Common::String &processName, Common::Point dst, int dir,
+                       bool userMove, bool leave) {
+	if (!_visible || !_enabled)
+		return false;
+
+	debug("character move %d,%d dir %d user %d leave %d", dst.x, dst.y, dir, userMove, leave);
 	notifyProcess(processName);
-	_pos = dst;
 	_shown = true;
-	bool r = direction(dir);
 
 	auto *screen = _engine->getCurrentScreen();
-	if (screen) {
-		auto objects = screen->find(dst);
-		for (auto &object : objects) {
-			auto region = object->getTrapRegion();
-			if (region && region->pointIn(dst)) {
-				debug("starting trap process");
-				_engine->runProcess(object, object->getTrapHandler());
+	RegionPtr region = screen ? screen->region() : RegionPtr();
+	if (!region || region->empty()) {
+		// no walk region on this screen: legacy teleport
+		_pos = dst;
+		bool r = direction(dir);
+		checkTrapRegions();
+		return r;
+	}
+
+	Common::Point clickPos = dst;
+
+	// make sure we start from a walkable spot
+	if (region->walkPointInside(_pos) < 1) {
+		Common::Point p = _pos;
+		if (region->snapSpiral(p, 8))
+			_pos = p;
+		else
+			_pos = region->center;
+	}
+
+	// resolve current state
+	if (_jokes)
+		stopMoving(true); // abort gesture, walk proceeds
+	switch (_walkState) {
+	case kWalkIdle:
+		break;
+	case kWalkTurning:
+		_targetDir = _direction;
+		_walkState = kWalkIdle;
+		break;
+	default: // currently walking: defer
+		if (leave) {
+			_walkState = kWalkLeavePending;
+			_exactMove = false;
+			_finalDir = dir;
+			_finalTarget = dst;
+			return true;
+		}
+		if (_walkState == kWalkWalking && dst == _finalTarget) {
+			_exactMove = userMove;
+			_finalDir = dir;
+			return true; // already going there
+		}
+		_walkState = kWalkRemovePending;
+		_exactMove = userMove;
+		_finalDir = dir;
+		_finalTarget = dst;
+		return true;
+	}
+
+	if (_pos == dst) { // turn in place
+		if (dir == -1)
+			return false;
+		_targetDir = dir;
+		_walkState = kWalkTurning;
+		return true;
+	}
+
+	if (!region->buildWalkPath(_path, _pos, dst)) {
+		// destination unreachable: find a "near way"
+		Common::Point p = dst;
+		bool onVertex = false;
+		bool ok = region->snapToNearestEdge(p, onVertex);
+		if (ok) {
+			if (!onVertex) {
+				if (p == clickPos)
+					ok = region->lineStepUntilInside(p, region->center.x - p.x, region->center.y - p.y);
+				else
+					ok = region->lineStepUntilInside(p, p.x - clickPos.x, p.y - clickPos.y);
+			} else {
+				ok = region->snapSpiral(p, 5);
 			}
 		}
+		if (ok)
+			ok = region->buildWalkPath(_path, _pos, p);
+		if (ok && _path.size() >= 2 &&
+		    wDistSq(_path[_path.size() - 1], _path[_path.size() - 2]) < 169.0) // < 13 px
+			_path.pop_back();
+		if (!ok || _path.size() < 2) {
+			_path.clear();
+			_processName.clear(); // nothing to wait for
+			return false;
+		}
+		dst = p;
 	}
-	return r;
+
+	if (leave && _walkState == kWalkIdle)
+		_walkState = kWalkLeaving;
+	if (_walkState != kWalkLeaving)
+		_walkState = kWalkWalking;
+
+	_pathIndex = 1;
+	_exactMove = userMove;
+	_finalDir = dir;
+	_finalTarget = dst;
+	startNextSegment();
+	if (_walkState == kWalkTurning) {
+		// setup decided the step is too short: just turn toward the target
+		if (dir == -1)
+			_targetDir = getDirectionForMovement(Common::Point(clickPos.x - _pos.x, clickPos.y - _pos.y));
+		else
+			_targetDir = dir;
+	}
+	return true;
 }
 
 void Character::pointTo(const Common::String &processName, Common::Point dst) {
-	debug("character point to stub %d,%d, process: %s", dst.x, dst.y, processName.c_str());
+	debug("character point to %d,%d, process: %s", dst.x, dst.y, processName.c_str());
 	notifyProcess(processName);
-	if (!_processName.empty() && !_engine->activeCurtain()) {
-		_engine->reactivate(_processName, "Character::pointTo");
-		_processName.clear();
-	}
 	_shown = true;
+	if (_walkState == kWalkIdle && !_jokes && dst != _pos) {
+		int dir = getDirectionForMovement(Common::Point(dst.x - _pos.x, dst.y - _pos.y));
+		if (_movementDirections == 8)
+			dir &= ~1;
+		if (dir != _direction) {
+			_targetDir = dir;
+			_walkState = kWalkTurning;
+			return; // the waiting process wakes once the turn completes
+		}
+	}
+	// already facing the target (or busy): wake the process on the next
+	// idle tick
 }
 
-bool Character::animate(int direction, int speed, bool jokes) {
-	if (direction == -1 || !_enabled)
+bool Character::animate(int animIndex, int speed, bool jokes) {
+	if (animIndex == -1 || !_enabled)
 		return false;
 
 	if (_stopped) {
@@ -198,9 +891,9 @@ bool Character::animate(int direction, int speed, bool jokes) {
 	}
 
 	auto character = jokes ? _engine->jokes() : this;
-	auto description = character->animationDescription(direction);
+	auto description = character->animationDescription(animIndex);
 	if (!description) {
-		warning("no %s animation %d", jokes ? "jokes" : "character", direction);
+		warning("no %s animation %d", jokes ? "jokes" : "character", animIndex);
 		return false;
 	}
 	auto animation = _engine->loadAnimation(description->filename);
@@ -216,14 +909,33 @@ bool Character::animate(int direction, int speed, bool jokes) {
 	_phase = 0;
 	_frames = _animation->frames();
 	_jokes = jokes;
+	// only jokes and walking advance frames; idle poses stay on frame 0
+	_standing = !jokes;
 	if (jokes)
-		_jokesDirection = direction;
-	else
-		_direction = direction;
+		_jokesDirection = animIndex;
+	// apply the perspective scale now: paint() can run before the next tick()
+	_animation->scale(perspectiveScale());
 	debug("character animation frames: %d, enabled: %d, visible: %d", _frames, _enabled, _visible);
 	return true;
 }
 
+float Character::perspectiveScale() const {
+	auto screen = _engine->getCurrentScreen();
+	float scale = screen ? screen->getZScale(_pos.y) : 1.0f;
+
+	// normalize by the canvas height ratio between the reference direction 4
+	// and the current animation; during a joke, the standing pose is used
+	if (phased() && _direction >= 0) {
+		int phase = _jokes ? 0 : CLIP(_walkPhase, 0, kAnimsPerDirection - 1);
+		int frame = _jokes ? 0 : CLIP(_phase, 0, _frames > 0 ? _frames - 1 : 0);
+		const AnimationDescription::Frame *num = frameAt(4, phase, frame);
+		const AnimationDescription::Frame *den = frameAt(_direction, phase, frame);
+		if (num && den && den->canvasH > 0)
+			scale = scale * num->canvasH / den->canvasH;
+	}
+	return scale;
+}
+
 bool Character::animate(Common::Point pos, int direction, int speed) {
 	debug("animate character: %d,%d %d %d", pos.x, pos.y, direction, speed);
 	auto ok = animate(direction, speed, true);
@@ -247,12 +959,13 @@ void Character::leave(const Common::String &processName) {
 void Character::tick(bool reactivate) {
 	if (!active())
 		return;
+
+	walkUpdateTick();
+
 	if (_animation) {
-		auto screen = _engine->getCurrentScreen();
-		auto scale = screen ? screen->getZScale(_pos.y) : 1;
-		_animation->scale(scale);
+		_animation->scale(perspectiveScale());
 
-		if (!_stopped && _phase >= 0 && _phase < _frames) {
+		if (!_stopped && !_standing && _phase >= 0 && _phase < _frames) {
 			_animation->tick();
 			_phase = _animation->phase();
 			if (_phase >= _frames) {
@@ -267,7 +980,8 @@ void Character::tick(bool reactivate) {
 		}
 	}
 
-	if (reactivate && !_processName.empty() && !_engine->activeCurtain()) {
+	if (_walkState == kWalkIdle &&
+	    reactivate && !_processName.empty() && !_engine->activeCurtain()) {
 		_engine->reactivate(_processName, "Character::tick");
 		_processName.clear();
 	}
@@ -283,21 +997,54 @@ bool Character::pointIn(Common::Point pos) const {
 }
 
 Common::Point Character::animationPosition() const {
-	Common::Point pos = _pos + _animationPos;
+	Common::Point pos = _pos;
 
-	if (_animation) {
-		pos.y -= _animation->visibleHeight();
-		pos.x -= _animation->visibleCenter();
-
-		if (_description) {
-			auto &frames = _description->frames;
-			if (_phase >= 0 && _phase < static_cast<int>(frames.size())) {
-				auto &frame = frames[_phase];
-				pos.x += frame.x * _animation->scale();
-				pos.y += frame.y * _animation->scale();
+	if (!_animation)
+		return pos;
+
+	float scale = _animation->scale();
+	int anchorX = 0, anchorY = 0;
+
+	if (_description && !_description->frames.empty()) {
+		auto &frames = _description->frames;
+		int idx = CLIP(_phase, 0, (int)frames.size() - 1);
+		const AnimationDescription::Frame &cur = frames[idx];
+		const AnimationDescription::Frame &first = frames[0];
+
+		// records describe a crop of the FLIC frame; since the whole FLIC
+		// frame is drawn, the crop position is folded into the anchor
+		if (phased()) {
+			if (_jokes) {
+				// gestures keep the feet anchored where the standing pose was
+				anchorX = (int)cur.w / 2 + first.offX - cur.x + ((int)first.canvasW / 2 - (int)first.w / 2) + cur.cropX;
+				anchorY = (int)cur.h + first.offY - cur.y + ((int)first.canvasH - (int)first.h) + cur.cropY;
+			} else {
+				// bottom-center of the logical canvas
+				anchorX = (int)cur.canvasW / 2 + cur.offX + cur.cropX;
+				anchorY = (int)cur.canvasH + cur.offY + cur.cropY;
 			}
+		} else {
+			anchorX = (int)first.w / 2 + cur.x + cur.cropX;
+			anchorY = (int)first.h + cur.y + cur.cropY;
 		}
+	} else {
+		// No frame metadata: anchor at the bottom-center of the already
+		// scaled bitmap.
+		pos.x -= _animation->width() / 2;
+		pos.y -= _animation->height();
+		if (_jokes)
+			pos += _animationPos;
+		return pos;
 	}
+
+	pos.x -= (int)(anchorX * scale);
+	pos.y -= (int)(anchorY * scale);
+
+	// Script-supplied gesture offsets are applied unscaled, and only while
+	// the gesture is playing.
+	if (_jokes)
+		pos += _animationPos;
+
 	return pos;
 }
 
@@ -320,18 +1067,20 @@ void Character::paint(Graphics::Surface &backbuffer, Common::Point pos) const {
 }
 
 int Character::z() const {
-	int y = _pos.y + _animationPos.y;
-	// fixme: add temp var : _movePos?
-	// debug("char z = %d", y);
-	return g_system->getHeight() - y;
+	return g_system->getHeight() - _pos.y;
 }
 
 void Character::reset() {
 	_fog.reset();
 	_shown = false;
 	_animation.reset();
+	_description = nullptr;
 	_phase = -1;
 	_frames = 0;
+	_jokes = false;
+	_walkPhase = 0;
+	_decodedFrame = 0;
+	cancelWalk();
 }
 
 void Character::setFog(Graphics::ManagedSurface *surface, int minZ, int maxZ) {
diff --git a/engines/agds/character.h b/engines/agds/character.h
index 825b5359753..e86c955cee4 100644
--- a/engines/agds/character.h
+++ b/engines/agds/character.h
@@ -69,8 +69,11 @@ class Character {
 
 	struct AnimationDescription {
 		struct Frame {
-			int x, y;
-			uint w, h;
+			int x, y;             // cumulative walk displacement from animation start
+			uint w, h;            // cropped bitmap size of this frame
+			uint canvasW, canvasH; // logical canvas size (anchor and scale reference)
+			int offX, offY;       // anchor tweak of the crop inside the logical canvas
+			int cropX, cropY;     // position of the crop inside the full FLIC frame
 		};
 
 		Common::String filename;
@@ -78,10 +81,85 @@ class Character {
 	};
 	Common::HashMap<uint, AnimationDescription> _animations;
 	const AnimationDescription *_description;
+	bool _standing;
 
-	bool animate(int direction, int speed, bool jokes);
+	// Phased characters (Black Mirror main cast) store 5 animations per
+	// facing direction: start(0), cycle A(1), cycle B(2), stop from A(3),
+	// stop from B(4). Their animation index is dir * 5 + phase.
+	static const int kAnimsPerDirection = 5;
+
+	bool phased() const {
+		return _movementDirections != 0 &&
+		       _animations.size() >= (uint)(_movementDirections * kAnimsPerDirection);
+	}
+
+	int animIndexFor(int dir, int phase) const {
+		return phased() ? dir * kAnimsPerDirection + phase : dir;
+	}
+
+	const AnimationDescription::Frame *frameAt(int dir, int phase, int frame) const {
+		const AnimationDescription *desc = animationDescription(animIndexFor(dir, phase));
+		if (!desc || desc->frames.empty())
+			return nullptr;
+		if (frame < 0)
+			frame = 0;
+		if (frame >= (int)desc->frames.size())
+			frame = desc->frames.size() - 1;
+		return &desc->frames[frame];
+	}
+
+	bool animate(int animIndex, int speed, bool jokes);
+	float perspectiveScale() const;
 	Common::Point animationPosition() const;
 
+	// --- Walk state machine (mirrors the original engine) ---
+	enum WalkState {
+		kWalkIdle = 0,
+		kWalkWalking = 1,          // normal walk along the path
+		kWalkLeaving = 2,          // walking off-screen
+		kWalkTurning = 3,          // turning in place only
+		kWalkRemovePending = 5,    // finish current cycle, then re-issue moveTo
+		kWalkLeavePending = 6,     // finish current cycle, then leave
+		kWalkStopPending = 9       // finish current cycle, play stop anim, idle
+	};
+
+	int _walkState;
+	int _targetDir;
+	Common::Array<Common::Point> _path;
+	uint _pathIndex;
+	Common::Point _segStart, _segTarget, _finalTarget;
+	int _finalDir;
+	bool _exactMove;
+	int _walkPhase;                // 0..4 walk phase, -1 = finished
+	int _cyclesA, _cyclesB;
+	bool _playStopAnim;
+	Common::Array<int> _cycleScales; // permyriad scale per planned cycle
+	int _scaleIdx;
+	int _errX, _errY, _errStepX, _errStepY;
+	int _errPeriodX, _errPeriodY, _errPhaseX, _errPhaseY;
+	int _walkTickCnt;
+	int _cycleErrX, _cycleErrY, _corrX, _corrY;
+	int _decodedFrame; // FLIC frame currently decoded into _animation
+
+	void walkUpdateTick();
+	void turnOneStep();
+	void startNextSegment();
+	void setCycleScale(uint idx, int scale);
+	void setupSegmentPhased(int dx, int dy);
+	int advanceWalkPhase(bool cont);
+	void advanceWalkFrame();
+	bool walkTickAdvance();
+	void stepErrorCorrection();
+	void walkingTick();
+	void arrive(int prevState);
+	void stopMoving(bool force);
+	bool loadWalkAnim();
+	Common::Point totalDisp(int dir, int phase) const;
+	int perspectiveScalePermyriad(int y) const;
+	int scaleForSegmentY(int simY, int targetY) const;
+	static double scalePermyriad(int v, int scale);
+	void checkTrapRegions();
+
 public:
 	Character(AGDSEngine *engine, const Common::String &name);
 	~Character();
@@ -131,17 +209,35 @@ public:
 	}
 
 	void position(Common::Point pos) {
+		// placing the character cancels any walk in progress
+		cancelWalk();
 		_pos = pos;
 	}
 
+	void cancelWalk() {
+		_walkState = kWalkIdle;
+		_path.clear();
+		_pathIndex = 0;
+		_targetDir = _direction;
+	}
+
 	Common::Point position() const {
 		return _pos;
 	}
 	bool pointIn(Common::Point pos) const;
 
 	void notifyProcess(const Common::String &processName);
-	bool moveTo(const Common::String &processName, Common::Point dst, int direction);
+	// leave: walk off-screen instead of stopping at the destination
+	bool moveTo(const Common::String &processName, Common::Point dst, int direction,
+	            bool userMove = false, bool leave = false);
 	void pointTo(const Common::String &processName, Common::Point dst);
+	// Graceful stop: finish the current walk cycle, play the stop
+	// animation, then face dir (-1 keeps the current facing).
+	void requestStop(int dir);
+
+	bool walking() const {
+		return _walkState != kWalkIdle;
+	}
 
 	bool direction(int dir);
 
@@ -149,6 +245,11 @@ public:
 		return _jokes ? _jokesDirection : _direction;
 	}
 
+	// base facing direction (0-15), ignoring any jokes gesture in progress
+	int baseDirection() const {
+		return _direction;
+	}
+
 	void tick(bool reactivate);
 	void paint(Graphics::Surface &backbuffer, Common::Point pos) const;
 
diff --git a/engines/agds/process_opcodes.cpp b/engines/agds/process_opcodes.cpp
index 1af843613d3..b355a413bf3 100644
--- a/engines/agds/process_opcodes.cpp
+++ b/engines/agds/process_opcodes.cpp
@@ -1649,7 +1649,7 @@ void Process::moveCharacter(bool usermove) {
 	if (character) {
 		auto region = _engine->loadRegion(regionName);
 		if (region) {
-			if (character->moveTo(_object->getName(), region->center, direction)) {
+			if (character->moveTo(_object->getName(), region->center, direction, usermove)) {
 				deactivate();
 			}
 		}
@@ -1705,7 +1705,7 @@ void Process::leaveCharacter(const Common::String &name, const Common::String &r
 	if (character) {
 		RegionPtr region = _engine->loadRegion(regionName);
 		debug("region: %s", region->toString().c_str());
-		if (character->moveTo(getName(), region->center, dir))
+		if (character->moveTo(getName(), region->center, dir, false, true))
 			deactivate();
 	} else
 		warning("character %s could not be found", name.c_str());
@@ -1733,7 +1733,7 @@ void Process::setCharacter() {
 	auto character = _engine->getCharacter(id);
 	if (character) {
 		if (dir == -1)
-			dir = character->direction();
+			dir = character->baseDirection();
 		auto region = _engine->loadRegion(regionName);
 		if (region) {
 			debug("setting character position to %d,%d", region->center.x, region->center.y);
@@ -1809,7 +1809,13 @@ void Process::stopCharacter() {
 	debug("stopCharacter: %s, direction: %d", name.c_str(), direction);
 	Character *character = _engine->getCharacter(name);
 	if (character) {
-		if (direction != -1) {
+		if (character->walking()) {
+			// graceful stop: finish the current cycle, play the stop
+			// animation, then face 'direction'
+			character->requestStop(direction);
+			character->notifyProcess(getName());
+			deactivate();
+		} else if (direction != -1) {
 			character->direction(direction);
 			character->notifyProcess(getName());
 			deactivate();
@@ -1917,7 +1923,7 @@ void Process::setCharacterNotifyVars() {
 	debug("setCharacterNotifyVars, tell: %s, direction: %s", arg1.c_str(), arg2.c_str());
 	auto character = _engine->currentCharacter();
 	_engine->setGlobal(arg1, 0);
-	_engine->setGlobal(arg2, character ? character->direction() : 0);
+	_engine->setGlobal(arg2, character ? character->baseDirection() : 0);
 	_engine->textLayout().setCharNotifyVar(arg1);
 	_engine->textLayout().setCharDirectionNotifyVar(arg2);
 }
diff --git a/engines/agds/region.cpp b/engines/agds/region.cpp
index c5599cd3cb7..50133216cdd 100644
--- a/engines/agds/region.cpp
+++ b/engines/agds/region.cpp
@@ -25,6 +25,8 @@
 #include "common/debug.h"
 #include "common/endian.h"
 #include "common/textconsole.h"
+#include "common/util.h"
+#include <math.h>
 
 namespace AGDS {
 
@@ -168,4 +170,418 @@ bool Region::pointIn(Common::Point point) const {
 	return false;
 }
 
+// Walk-region geometry, reimplemented from the original engine.
+
+static inline double distSq(Common::Point a, Common::Point b) {
+	double dx = a.x - b.x, dy = a.y - b.y;
+	return dx * dx + dy * dy;
+}
+
+static inline double cross2(double ax, double ay, double bx, double by) {
+	return ax * by - ay * bx;
+}
+
+// Intersect edge a1->a2 with segment b1->b2.
+// Returns 1 on a proper intersection, 0 when none, -2 when collinear.
+static int segIntersect(Common::Point a1, Common::Point a2,
+                        Common::Point b1, Common::Point b2,
+                        double &tSegOut, double &tEdgeOut,
+                        Common::Point &hit) {
+	double rx = a2.x - a1.x, ry = a2.y - a1.y;
+	double sx = b2.x - b1.x, sy = b2.y - b1.y;
+	double denom = cross2(rx, ry, sx, sy);
+	double qpx = b1.x - a1.x, qpy = b1.y - a1.y;
+	if (denom == 0.0) {
+		if (cross2(qpx, qpy, rx, ry) == 0.0)
+			return -2;
+		return 0;
+	}
+	double t = cross2(qpx, qpy, sx, sy) / denom; // along edge
+	double u = cross2(qpx, qpy, rx, ry) / denom; // along segment
+	if (t < 0.0 || t > 1.0 || u < 0.0 || u > 1.0)
+		return 0;
+	tEdgeOut = t;
+	tSegOut = u;
+	hit.x = (int16)(a1.x + t * rx);
+	hit.y = (int16)(a1.y + t * ry);
+	return 1;
+}
+
+int Region::walkPointInside(Common::Point p) const {
+	if (p == center)
+		return 1;
+
+	for (uint r = 0; r < regions.size(); ++r) {
+		const PointsType &poly = regions[r];
+		uint n = poly.size();
+		if (n < 3)
+			continue;
+
+		int crossings = 0, leftGrazes = 0, rightGrazes = 0;
+		double rayDX = center.x - p.x, rayDY = center.y - p.y;
+
+		for (uint i = 0; i < n; ++i) {
+			Common::Point v0 = poly[i];
+			Common::Point v1 = poly[(i + 1) % n];
+			if (v1 == p)
+				return 0; // exactly on a vertex
+
+			double tSeg, tEdge;
+			Common::Point hit;
+			int res = segIntersect(v0, v1, p, center, tSeg, tEdge, hit);
+			if (res == 0 || res == -2)
+				continue;
+			if (tEdge > 0.0 && tEdge < 1.0) {
+				crossings++;
+			} else {
+				// vertex touch: classify by which side of the ray the
+				// edge's other endpoint lies, to dedup below
+				Common::Point other = (tEdge <= 0.0) ? v1 : v0;
+				double side = cross2(rayDX, rayDY, other.x - p.x, other.y - p.y);
+				crossings++;
+				if (side < 0)
+					leftGrazes++;
+				else
+					rightGrazes++;
+			}
+		}
+		crossings -= MIN(leftGrazes, rightGrazes);
+		if (crossings & 1)
+			return -1;
+	}
+	return 1;
+}
+
+bool Region::snapSpiral(Common::Point &p, int maxRadius) const {
+	if (walkPointInside(p) > 0)
+		return true;
+	for (int radius = 1; radius <= maxRadius; ++radius) {
+		for (int dx = -radius; dx <= radius; ++dx) {
+			Common::Point c(p.x + dx, p.y + radius);
+			if (walkPointInside(c) > 0) { p = c; return true; }
+			c = Common::Point(p.x + dx, p.y - radius);
+			if (walkPointInside(c) > 0) { p = c; return true; }
+		}
+		for (int dy = -radius + 1; dy <= radius - 1; ++dy) {
+			Common::Point c(p.x + radius, p.y + dy);
+			if (walkPointInside(c) > 0) { p = c; return true; }
+			c = Common::Point(p.x - radius, p.y + dy);
+			if (walkPointInside(c) > 0) { p = c; return true; }
+		}
+	}
+	return false;
+}
+
+bool Region::lineStepUntilInside(Common::Point &p, int dx, int dy) const {
+	if (dx == 0 && dy == 0)
+		return false;
+	int adx = ABS(dx), ady = ABS(dy);
+	int sx = dx > 0 ? 1 : -1, sy = dy > 0 ? 1 : -1;
+	int x = p.x, y = p.y;
+	int err = (adx > ady ? adx : -ady) / 2;
+	for (int i = 0; i < 200; ++i) {
+		int e = err;
+		if (e > -adx) { err -= ady; x += sx; }
+		if (e < ady)  { err += adx; y += sy; }
+		if (walkPointInside(Common::Point(x, y)) > 0) {
+			p = Common::Point(x, y);
+			return true;
+		}
+	}
+	return false;
+}
+
+bool Region::snapToNearestEdge(Common::Point &p, bool &onVertex) const {
+	double best = 1e18;
+	Common::Point bestPt;
+	bool bestOnVertex = false;
+	bool found = false;
+
+	for (uint r = 0; r < regions.size(); ++r) {
+		const PointsType &poly = regions[r];
+		uint n = poly.size();
+		for (uint i = 0; i < n; ++i) {
+			Common::Point v0 = poly[i];
+			Common::Point v1 = poly[(i + 1) % n];
+			double ex = v1.x - v0.x, ey = v1.y - v0.y;
+			double len2 = ex * ex + ey * ey;
+			double t = len2 > 0 ? ((p.x - v0.x) * ex + (p.y - v0.y) * ey) / len2 : 0.0;
+			if (t > 0.95) t = 1.0;
+			if (t < 0.05) t = 0.0;
+			Common::Point cand((int16)(v0.x + t * ex + 0.5), (int16)(v0.y + t * ey + 0.5));
+			double d = distSq(p, cand);
+			if (d < best) {
+				best = d;
+				bestPt = cand;
+				bestOnVertex = (t == 0.0 || t == 1.0);
+				found = true;
+			}
+		}
+	}
+	if (!found)
+		return false;
+	p = bestPt;
+	onVertex = bestOnVertex;
+	return true;
+}
+
+namespace {
+
+struct WalkCrossing {
+	Common::Point pt;
+	double dist2;
+	int poly;
+	int edgeStart; // index of the edge's first vertex
+	int hits;
+};
+
+// Walk a few pixels from 'hit' toward 'to'; a vertex graze that stays
+// walkable is not a real boundary crossing.
+static bool grazeIsWalkable(const Region &rgn, Common::Point hit, Common::Point to) {
+	int dx = to.x - hit.x, dy = to.y - hit.y;
+	if (dx == 0 && dy == 0)
+		return true;
+	double len = sqrt((double)dx * dx + (double)dy * dy);
+	for (int i = 1; i <= 3; ++i) {
+		Common::Point c((int16)(hit.x + dx * i / len + 0.5), (int16)(hit.y + dy * i / len + 0.5));
+		if (rgn.walkPointInside(c) < 0)
+			return false;
+	}
+	return true;
+}
+
+static void collectCrossings(const Region &rgn, Common::Point from, Common::Point to,
+                             Common::Array<WalkCrossing> &out) {
+	for (uint r = 0; r < rgn.regions.size(); ++r) {
+		const Region::PointsType &poly = rgn.regions[r];
+		uint n = poly.size();
+		if (n < 3)
+			continue;
+		for (uint i = 0; i < n; ++i) {
+			Common::Point v0 = poly[i];
+			Common::Point v1 = poly[(i + 1) % n];
+			double tSeg, tEdge;
+			Common::Point hit;
+			if (segIntersect(v0, v1, from, to, tSeg, tEdge, hit) != 1)
+				continue;
+			// vertex graze filter: hits within 2 px of an edge endpoint that
+			// remain walkable are ignored
+			if ((distSq(hit, v0) < 4.0 || distSq(hit, v1) < 4.0) && grazeIsWalkable(rgn, hit, to))
+				continue;
+			// endpoints resting on a boundary edge (frequent after an
+			// earlier walk ended near a wall) are not real crossings either
+			if ((distSq(hit, from) < 4.0 || distSq(hit, to) < 4.0) && grazeIsWalkable(rgn, hit, to))
+				continue;
+
+			bool merged = false;
+			for (uint k = 0; k < out.size(); ++k) {
+				if (out[k].pt == hit) {
+					out[k].hits++;
+					merged = true;
+					break;
+				}
+			}
+			if (merged)
+				continue;
+
+			WalkCrossing c;
+			c.pt = hit;
+			c.dist2 = distSq(hit, to);
+			c.poly = r;
+			c.edgeStart = i;
+			c.hits = 1;
+			// insert sorted ascending by distance to destination
+			uint pos = 0;
+			while (pos < out.size() && out[pos].dist2 < c.dist2)
+				pos++;
+			out.insert_at(pos, c);
+		}
+	}
+}
+
+static bool bresenhamInside(const Region &rgn, Common::Point a, Common::Point b) {
+	int adx = ABS(b.x - a.x), ady = ABS(b.y - a.y);
+	int sx = b.x > a.x ? 1 : -1, sy = b.y > a.y ? 1 : -1;
+	int x = a.x, y = a.y;
+	int err = (adx > ady ? adx : -ady) / 2;
+	while (x != b.x || y != b.y) {
+		int e = err;
+		if (e > -adx) { err -= ady; x += sx; }
+		if (e < ady)  { err += adx; y += sy; }
+		if (rgn.walkPointInside(Common::Point(x, y)) < 0)
+			return false;
+	}
+	return true;
+}
+
+static bool anyCrossing(const Region &rgn, Common::Point a, Common::Point b) {
+	for (uint r = 0; r < rgn.regions.size(); ++r) {
+		const Region::PointsType &poly = rgn.regions[r];
+		uint n = poly.size();
+		if (n < 3)
+			continue;
+		for (uint i = 0; i < n; ++i) {
+			double tSeg, tEdge;
+			Common::Point hit;
+			if (segIntersect(poly[i], poly[(i + 1) % n], a, b, tSeg, tEdge, hit) != 1)
+				continue;
+			if ((distSq(hit, poly[i]) < 4.0 || distSq(hit, poly[(i + 1) % n]) < 4.0) &&
+			    grazeIsWalkable(rgn, hit, b))
+				continue;
+			return true;
+		}
+	}
+	return false;
+}
+
+static bool collinear(Common::Point a, Common::Point b, Common::Point c) {
+	return (double)(b.x - a.x) * (c.y - a.y) == (double)(b.y - a.y) * (c.x - a.x);
+}
+
+static void straightenPath(const Region &rgn, Common::Array<Common::Point> &path) {
+	bool changed = true;
+	while (changed && path.size() > 2) {
+		changed = false;
+		for (uint i = 1; i + 1 < path.size(); ++i) {
+			Common::Point a = path[i - 1], b = path[i], c = path[i + 1];
+			bool drop = distSq(a, b) < 4.0 || distSq(b, c) < 4.0 || distSq(a, c) < 4.0 ||
+			            collinear(a, b, c) ||
+			            (!anyCrossing(rgn, a, c) && bresenhamInside(rgn, a, c));
+			if (drop) {
+				path.remove_at(i);
+				changed = true;
+				break;
+			}
+		}
+	}
+}
+
+static const int kDirStep[8][2] = {
+	{0, -1}, {1, -1}, {1, 0}, {1, 1}, {0, 1}, {-1, 1}, {-1, 0}, {-1, -1}
+};
+
+static void nudgeCornersInside(const Region &rgn, Common::Array<Common::Point> &path) {
+	for (uint i = 0; i < path.size(); ++i) {
+		if (rgn.walkPointInside(path[i]) >= 1)
+			continue;
+		bool inside[8];
+		int count = 0;
+		for (int k = 0; k < 8; ++k) {
+			inside[k] = rgn.walkPointInside(
+			                Common::Point(path[i].x + kDirStep[k][0], path[i].y + kDirStep[k][1])) > 0;
+			if (inside[k])
+				count++;
+		}
+		if (!count) {
+			warning("cannot shift path corner into walk region");
+			continue;
+		}
+		// middle of the contiguous inside arc
+		int start = 0;
+		while (start < 8 && !(inside[start] && !inside[(start + 7) & 7]))
+			start++;
+		if (start == 8) { // all inside
+			start = 0;
+		}
+		int len = 0;
+		while (len < 8 && inside[(start + len) & 7])
+			len++;
+		int mid = (start + (len - 1) / 2) & 7;
+		path[i].x += kDirStep[mid][0];
+		path[i].y += kDirStep[mid][1];
+	}
+}
+
+static double pathLength(const Common::Array<Common::Point> &path) {
+	double len = 0;
+	for (uint i = 1; i < path.size(); ++i)
+		len += sqrt(distSq(path[i - 1], path[i]));
+	return len;
+}
+
+static void detourAroundPolygon(const Region &rgn, const WalkCrossing &entry,
+                                const WalkCrossing &exit,
+                                Common::Array<Common::Point> &path) {
+	const Region::PointsType &poly = rgn.regions[entry.poly];
+	int n = poly.size();
+
+	// forward (ascending vertex order): advance then push
+	Common::Array<Common::Point> fwd;
+	fwd.push_back(entry.pt);
+	{
+		int v = entry.edgeStart, steps = 0;
+		do {
+			v = (v + 1) % n;
+			fwd.push_back(poly[v]);
+		} while (v != exit.edgeStart && ++steps <= n);
+	}
+	fwd.push_back(exit.pt);
+	straightenPath(rgn, fwd);
+	double lenF = pathLength(fwd);
+
+	// backward (descending vertex order): push then retreat
+	Common::Array<Common::Point> bwd;
+	bwd.push_back(entry.pt);
+	{
+		int v = entry.edgeStart, steps = 0;
+		while (v != exit.edgeStart && steps <= n) {
+			bwd.push_back(poly[v]);
+			v = (v + n - 1) % n;
+			steps++;
+		}
+	}
+	bwd.push_back(exit.pt);
+	straightenPath(rgn, bwd);
+	double lenB = pathLength(bwd);
+
+	const Common::Array<Common::Point> &chosen = (lenB <= lenF) ? bwd : fwd;
+	for (uint i = 0; i < chosen.size(); ++i)
+		path.push_back(chosen[i]);
+}
+
+} // anonymous namespace
+
+bool Region::buildWalkPath(Common::Array<Common::Point> &path,
+                           Common::Point from, Common::Point to) const {
+	path.clear();
+	if (walkPointInside(from) < 1 || walkPointInside(to) < 1)
+		return false;
+	path.push_back(from);
+
+	Common::Array<WalkCrossing> crossings;
+	collectCrossings(*this, from, to, crossings);
+
+	if (crossings.size() & 1) {
+		// odd count: degenerate touch, try to drop a duplicate hit
+		int dup = -1, dupCount = 0;
+		for (uint i = 0; i < crossings.size(); ++i) {
+			if (crossings[i].hits == 2) {
+				dupCount++;
+				dup = i;
+			}
+		}
+		if (dupCount == 1) {
+			crossings.remove_at(dup);
+		} else if (crossings.size() == 1 && (crossings[0].pt == from || crossings[0].pt == to)) {
+			crossings.remove_at(0);
+		} else {
+			debug("buildWalkPath: %u crossings (odd), giving up", crossings.size());
+			path.clear();
+			return false;
+		}
+	}
+
+	// crossings are sorted ascending by distance to the destination, so the
+	// highest index is the first one hit walking from the start
+	for (int i = (int)crossings.size() - 1; i >= 1; i -= 2)
+		detourAroundPolygon(*this, crossings[i], crossings[i - 1], path);
+
+	if (path.back() != to)
+		path.push_back(to);
+
+	straightenPath(*this, path);
+	nudgeCornersInside(*this, path);
+	return !path.empty();
+}
+
 } // namespace AGDS
diff --git a/engines/agds/region.h b/engines/agds/region.h
index 1489f88f161..0654025acba 100644
--- a/engines/agds/region.h
+++ b/engines/agds/region.h
@@ -48,6 +48,21 @@ struct Region {
 	bool empty() const {
 		return regions.empty();
 	}
+
+	// Walk-region geometry: polygon 0 is the walkable boundary, further
+	// polygons are obstacles; center is an anchor inside the walkable area.
+
+	// 1 = inside, 0 = exactly on a vertex, -1 = outside
+	int walkPointInside(Common::Point p) const;
+	// move p to the closest walkable pixel within maxRadius
+	bool snapSpiral(Common::Point &p, int maxRadius) const;
+	// step from p along (dx, dy) until a walkable pixel is found
+	bool lineStepUntilInside(Common::Point &p, int dx, int dy) const;
+	// project p onto the nearest polygon edge
+	bool snapToNearestEdge(Common::Point &p, bool &onVertex) const;
+	// build waypoints from 'from' to 'to', empty on failure
+	bool buildWalkPath(Common::Array<Common::Point> &path,
+	                   Common::Point from, Common::Point to) const;
 };
 
 } // End of namespace AGDS


Commit: cb68a7306d812033f33e94b0058964c1c898df74
    https://github.com/scummvm/scummvm/commit/cb68a7306d812033f33e94b0058964c1c898df74
Author: nikaiw (devillers.nicolas at gmail.com)
Date: 2026-07-28T14:05:32+02:00

Commit Message:
AGDS: Don't restore character state from script-created screen patches

Patches created by scripts before a first visit carry default values
(character hidden, position 0,0). Applying them on screen entry left
the character invisible. Only restore the character from patches that
actually saved the screen state.

Changed paths:
    engines/agds/agds.cpp


diff --git a/engines/agds/agds.cpp b/engines/agds/agds.cpp
index e7e18037866..90d125d9eeb 100644
--- a/engines/agds/agds.cpp
+++ b/engines/agds/agds.cpp
@@ -362,7 +362,9 @@ void AGDSEngine::loadScreen(const Common::String &name, ScreenLoadingType loadin
 
 	if (doPatch) {
 		_currentScreen->load(patch);
-		if (_currentCharacter) {
+		// script-created patches never captured the screen and carry no
+		// character state; applying their defaults would hide the character
+		if (_currentCharacter && patch->screenSaved) {
 			_currentCharacter->visible(patch->characterPresent);
 			_currentCharacter->position(patch->characterPosition);
 			_currentCharacter->direction(patch->characterDirection);




More information about the Scummvm-git-logs mailing list